|
¥{{ item.pay_price }}
@@ -252,6 +282,17 @@
:color="goodsItm.use_status == UseStatusEnum.USE.value ? 'green' : ''"
>{{ UseStatusEnum[goodsItm.use_status].name }}
+
+
+ 企油通:
+ {{ qytText(goodsItm.qyt.oil_status_zsy) }}
+
+
+
+ 退款:
+ {{ refundText(goodsItm.qyt.refund_status) }}
+
+
+
+
+
+
+
@@ -312,7 +358,9 @@ import {
OilStatusEnum,
UseStatusEnum
} from '@/common/enum/order'
-import { DeliveryForm, CancelForm, CancelShareForm, ShareForm, PayForm } from './modules'
+import { DeliveryForm, CancelForm, CancelShareForm } from './modules'
+// 企油通的发放/加油/退款/发票与合作商订单完全同构,组件和接口都直接复用
+import { OilForm, UseForm, ShareForm, PayForm, RefundForm, InvoiceModal } from '@/views/partners/order/modules'
// 表格字段
const columns = [
@@ -330,9 +378,24 @@ const columns = [
{
title: '操作',
dataIndex: 'action',
- width: '180px',
+ width: '220px',
scopedSlots: { customRender: 'action' }
},
+ {
+ title: '司机',
+ align: 'center',
+ scopedSlots: { customRender: 'driver' }
+ },
+ {
+ title: '企油通单号',
+ align: 'center',
+ scopedSlots: { customRender: 'qyt' }
+ },
+ {
+ title: '加油信息',
+ align: 'center',
+ scopedSlots: { customRender: 'consume' }
+ },
{
title: '实付款',
align: 'center',
@@ -407,7 +470,11 @@ export default {
CancelForm,
CancelShareForm,
ShareForm,
- PayForm
+ PayForm,
+ OilForm,
+ UseForm,
+ RefundForm,
+ InvoiceModal
},
data () {
return {
@@ -565,14 +632,68 @@ export default {
handleCancelShare (record) {
this.$refs.CancelShareForm.show(record)
},
- // 手动分油
+ // ================================================================
+ // 企油通:发放 / 加油 / 退款 / 发票
+ // 这些操作的对象是订单商品行上挂的发放单(goodsItm.qyt,即 partners_order 记录),
+ // 调的接口与合作商订单页是同一套 /partners.order/*
+ // ================================================================
+
+ // 重新发放(仅「发放失败」可用;会重新扣减下单用户余额)
handleShare (record) {
this.$refs.ShareForm.show(record)
},
- // 付款码
+ // 加油链接(企油通 7.15 免登 H5)
handlePay (record) {
this.$refs.PayForm.show(record)
},
+ // 重推发放结果(小程序单没有下游平台,等于把状态重新同步到用户端)
+ handleOil (record) {
+ this.$refs.OilForm.show(record)
+ },
+ // 重推加油结果
+ handleUse (record) {
+ this.$refs.UseForm.show(record)
+ },
+ // 退款
+ handleRefund (record) {
+ this.$refs.RefundForm.show(record)
+ },
+ // 企业发票(企油通 7.14)
+ handleInvoice (record) {
+ this.$refs.InvoiceModal.show(record)
+ },
+
+ // 企油通发放态文案 / 颜色
+ qytText (v) {
+ return { 0: '未提交', 1: '发放中', 2: '发放成功', 3: '发放失败' }[v] || '未知'
+ },
+ qytColor (v) {
+ return { 1: 'blue', 2: 'green', 3: 'red' }[v] || ''
+ },
+
+ // 退款状态文案 / 颜色
+ refundText (v) {
+ return { 0: '未退款', 1: '退款中', 2: '已退款', 3: '退款失败', 4: '待人工确认' }[v] || '未知'
+ },
+ refundColor (v) {
+ return { 1: 'blue', 2: 'green', 3: 'red', 4: 'orange' }[v] || ''
+ },
+ // 发放成功才可能退款;已经有退款记录的单总是可以打开看流水
+ canRefund (item) {
+ if (item.oilCardType !== 'zsy_ticket' || item.oil_status_zsy !== 2 || item.oil_status !== 1) {
+ return false
+ }
+ return item.refund_status > 0 || Number(this.remainAmount(item)) > 0
+ },
+ // 剩余未加油金额:发放金额 − 已加油 −(已退款时)退款金额
+ remainAmount (item) {
+ const refunded = item.refund_status === 2 ? Number(item.refund_amount || 0) : 0
+ const remain = Number(item.total_price || 0) - Number(item.used_amount || 0) - refunded
+ return Math.max(0, remain).toFixed(2)
+ },
+ refundLabel (item) {
+ return { 2: '退款记录', 4: '确认退款' }[item.refund_status] || '退款'
+ },
formatTime (timestamp) {
const date = new Date(timestamp)
const year = date.getFullYear()
@@ -649,4 +770,15 @@ export default {
font-size: 20px;
font-weight: 500;
}
+// 操作栏:按钮较多会换行,横向纵向都留出间距(覆盖全局 .actions>a 的 8px 右边距)
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 16px;
+
+ & > a {
+ margin-right: 0 !important;
+ white-space: nowrap;
+ }
+}
diff --git a/admin-vue/src/views/partners/order/modules/RefundForm.vue b/admin-vue/src/views/partners/order/modules/RefundForm.vue
index 2bf2387..661ac0c 100644
--- a/admin-vue/src/views/partners/order/modules/RefundForm.vue
+++ b/admin-vue/src/views/partners/order/modules/RefundForm.vue
@@ -10,7 +10,7 @@
油卡编号:{{ record.oilCardNo }}
- 司机:{{ record.driver_name || '-' }} {{ record.mobile }}
+ 司机:{{ record.driver_name || '-' }} {{ record.mobile }}小程序订单
订单金额:¥{{ record.total_price }} 已加油:¥{{ record.used_amount || '0.00' }}({{ record.used_num || 0 }} 次)
退款金额:¥{{ refundAmount }}
@@ -28,7 +28,7 @@
一张订单可以分多次加油,退款金额固定为剩余未加油金额,已加油的部分不退,每单只能成功退一次。
- 退款会先用企油通 7.17 查询司机支付凭证,再用 7.16 把凭证额度调低退款金额;成功后原路回补合作商余额,并推送油卡变动通知。
+ 退款会先用企油通 7.17 查询司机支付凭证,再用 7.16 把凭证额度调低退款金额;成功后{{ backText }}。
额度在凭证上按司机累计,若凭证可用余额不足退款金额(被该司机其他订单用掉),会直接拒绝。
@@ -113,6 +113,12 @@ export default {
statusColor () {
return STATUS_COLOR[this.status] || ''
},
+ // 退款成功后钱退到哪里:合作商单回补合作商余额,小程序单退回下单用户余额
+ backText () {
+ return this.record.source === 'mall'
+ ? '把钱退回下单用户的账户余额(按实付金额折算),并同步订单状态到用户端'
+ : '原路回补合作商余额,并推送油卡变动通知'
+ },
// 已退款显示实际退款金额;否则为剩余未加油金额(后端同口径计算)
refundAmount () {
if (this.status === 2) {
diff --git a/admin-vue/src/views/setting/Qyt.vue b/admin-vue/src/views/setting/Qyt.vue
index 5a1d5f4..8dde9d0 100644
--- a/admin-vue/src/views/setting/Qyt.vue
+++ b/admin-vue/src/views/setting/Qyt.vue
@@ -55,7 +55,23 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -127,6 +143,8 @@ export default {
'h5_base_url': record.h5_base_url,
'h5_app_id': record.h5_app_id,
'consume_notify_url': record.consume_notify_url,
+ 'waybill_notify_url': record.waybill_notify_url,
+ 'waybill_notify': !!Number(record.waybill_notify),
'log': !!Number(record.log)
})
})
@@ -144,7 +162,7 @@ export default {
// 提交到后端api
onFormSubmit (values) {
this.isLoading = true
- const form = { ...values, log: values.log ? 1 : 0 }
+ const form = { ...values, log: values.log ? 1 : 0, waybill_notify: values.waybill_notify ? 1 : 0 }
Api.update(this.key, { form })
.then(result => {
this.$message.success(result.message, 1.5)
@@ -177,4 +195,7 @@ export default {
.input-inst {
width: 70%;
}
+.warning-tip {
+ color: #faad14;
+}
diff --git a/mobile-uniapp/api/user.js b/mobile-uniapp/api/user.js
index 6e3c30c..3aa038e 100644
--- a/mobile-uniapp/api/user.js
+++ b/mobile-uniapp/api/user.js
@@ -5,7 +5,9 @@ const api = {
userInfo: 'user/info',
assets: 'user/assets',
bindMobile: 'user/bindMobile',
- personal: 'user/personal'
+ personal: 'user/personal',
+ realname: 'user/realname',
+ saveRealname: 'user/saveRealname'
}
// 当前登录的用户信息
@@ -32,3 +34,13 @@ export const bindMobile = (data, option) => {
export const personal = (data, option) => {
return request.post(api.personal, data, option)
}
+
+// 实名信息(姓名/身份证号/车牌号):油券发放实名制,下单前必须补全
+export const realname = (param, option) => {
+ return request.get(api.realname, param, option)
+}
+
+// 保存实名信息
+export const saveRealname = (data, option) => {
+ return request.post(api.saveRealname, data, option)
+}
diff --git a/mobile-uniapp/config.js b/mobile-uniapp/config.js
index 66a91a0..63a65cd 100644
--- a/mobile-uniapp/config.js
+++ b/mobile-uniapp/config.js
@@ -5,7 +5,7 @@ module.exports = {
// 必填: 后端api地址, 斜杠/结尾, 参照下面格式
// 例如: https://www.你的域名.com/index.php?s=/api/
- apiUrl: "https://www.hmzkw.com/index.php?s=/api/",
+ apiUrl: "http://47.104.73.7/index.php?s=/api/",
/**
* 是否启用商城设置缓存
diff --git a/mobile-uniapp/pages.json b/mobile-uniapp/pages.json
index bfc2980..2f9f3ef 100644
--- a/mobile-uniapp/pages.json
+++ b/mobile-uniapp/pages.json
@@ -5,7 +5,7 @@
"borderStyle": "black",
"backgroundColor": "#ffffff",
"list": [{
- "pagePath": "pages/station/index",
+ "pagePath": "pages/index/index",
"iconPath": "static/tabbar/home.png",
"selectedIconPath": "static/tabbar/home-active.png",
"text": "首页"
@@ -30,15 +30,15 @@
}]
},
"pages": [ //pages数组中第一项表示应用启动页,参考:https://uniapp.dcloud.io/collocation/pages
- {
- "path": "pages/station/index"
- },
{
"path": "pages/index/index",
"style": {
"enablePullDownRefresh": true
}
},
+ {
+ "path": "pages/station/index"
+ },
{
"path": "pages/category/index",
"style": {
@@ -89,6 +89,12 @@
"navigationBarTitleText": "个人信息"
}
},
+ {
+ "path": "pages/user/realname/index",
+ "style": {
+ "navigationBarTitleText": "实名信息"
+ }
+ },
{
"path": "pages/article/index",
"style": {
diff --git a/mobile-uniapp/pages/category/index.vue b/mobile-uniapp/pages/category/index.vue
index 047fb27..f7bcb08 100644
--- a/mobile-uniapp/pages/category/index.vue
+++ b/mobile-uniapp/pages/category/index.vue
@@ -2,7 +2,6 @@
-
@@ -16,7 +15,6 @@
+
+
diff --git a/mobile-uniapp/utils/request/core/request.js b/mobile-uniapp/utils/request/core/request.js
index 2c0fdd6..bfeddd1 100644
--- a/mobile-uniapp/utils/request/core/request.js
+++ b/mobile-uniapp/utils/request/core/request.js
@@ -81,9 +81,7 @@ export default class request {
data.data.referrer_id = uni.getStorageSync('uid');
data.data.latitude = uni.getStorageSync('latitude');
data.data.longitude = uni.getStorageSync('longitude');
- data.data.province = uni.getStorageSync('province');
- data.data.city = uni.getStorageSync('city');
- data.data.district = uni.getStorageSync('district');
+ // 商品/分类不再按省份过滤,省市区不再随请求下发(原先后端会据此过滤油券)
// 请求数据
let requestInfo,
// 是否运行过请求开始钩子
diff --git a/server-tp6/app/api/controller/Order.php b/server-tp6/app/api/controller/Order.php
index 4bfbc6e..9935e55 100644
--- a/server-tp6/app/api/controller/Order.php
+++ b/server-tp6/app/api/controller/Order.php
@@ -24,6 +24,10 @@ use app\store\model\Company as CompanyModel;
use app\store\model\Express as ExpressModel;
use app\common\enum\order\PayType as OrderPayTypeEnum;
use app\store\model\order\QrCode as QrCodeModel;
+use app\common\model\qyt\Enterprise as EnterpriseModel;
+use app\common\service\QytOil;
+use app\api\service\User as UserService;
+use think\facade\Db;
use cores\exception\BaseException;
use think\facade\Request;
use think\response\Json;
@@ -309,9 +313,77 @@ class Order extends Controller
*/
public function qrCode(): Json
{
- // 企油通改造:小程序油品发放/核销链路已停用(上游定聚已下线)。
- // 保留接口避免小程序端报错,统一返回提示;如需恢复请改走 QytOil。
- return $this->renderError('该功能已停用');
+ $orderGoodsId = (int)$this->request->param('orderGoodsId');
+ [$order, $error] = $this->findOilOrder($orderGoodsId);
+ if ($error !== '') {
+ return $this->renderError($error);
+ }
+ // 免登链接要带本单企业的 creditcode(营业执照号),把登录固定到该企业
+ $enterprise = EnterpriseModel::detail((int)$order['qyt_enterprise_id']);
+ if (!$enterprise) {
+ return $this->renderError('本单绑定的企油通企业不存在,无法获取加油地址');
+ }
+ if ($enterprise['status'] !== 'approved') {
+ return $this->renderError('企业尚未通过企油通审核,暂时无法加油');
+ }
+ $h5Url = QytOil::forEnterprise($enterprise)->h5Url((string)$order['mobile']);
+ if (!$h5Url) {
+ return $this->renderError('获取加油地址失败,请稍后重试');
+ }
+ // 留痕
+ (new QrCodeModel())->add([
+ 'oilCardNo' => $order['oilCardNo'],
+ 'qrCode' => $h5Url,
+ 'amount' => $order['goods_price'],
+ 'order_id' => (int)$order['mall_order_id'],
+ 'user_id' => (int)$order['user_id'],
+ 'store_id' => (int)$order['store_id'],
+ ]);
+ return $this->renderSuccess(['qrCode' => $h5Url], '提交成功');
+ }
+
+ /**
+ * 按订单商品行找到对应的企油通发放单,并校验是否还能加油
+ *
+ * 小程序单在支付成功后由 MallOil::create 建了一条 yoshop_partners_order
+ * (source='mall',mall_order_goods_id 指回这里的订单商品行)。
+ *
+ * @param int $orderGoodsId
+ * @return array [发放单, 错误信息]
+ * @throws BaseException
+ */
+ private function findOilOrder(int $orderGoodsId): array
+ {
+ if ($orderGoodsId <= 0) {
+ return [null, '缺少参数 orderGoodsId'];
+ }
+ $userId = (int)UserService::getCurrentLoginUserId();
+ $orderGoods = Db::name('order_goods')->where('order_goods_id', '=', $orderGoodsId)->find();
+ if (!$orderGoods) {
+ return [null, '未找到订单商品'];
+ }
+ // 越权校验:只能取自己订单的加油地址
+ if ((int)$orderGoods['user_id'] !== $userId) {
+ return [null, '未找到订单商品'];
+ }
+ $order = Db::name('partners_order')
+ ->where('source', '=', 'mall')
+ ->where('mall_order_goods_id', '=', $orderGoodsId)
+ ->find();
+ if (!$order) {
+ return [null, '该订单还没有发放记录'];
+ }
+ if ((int)$order['oil_status'] !== 1) {
+ return [null, '油费还在发放中,请稍后再试'];
+ }
+ if ((int)$order['refund_status'] === 2) {
+ return [null, '该订单已退款,不能再加油'];
+ }
+ // 一单可以分多次加油:没加完的单继续给加油地址
+ if ((float)$order['used_amount'] + 0.005 >= (float)$order['total_price']) {
+ return [null, '该订单金额已全部加油'];
+ }
+ return [$order, ''];
}
/**
@@ -321,9 +393,10 @@ class Order extends Controller
*/
public function qrCodeImg(): Json
{
- // 企油通改造:小程序油品发放/核销链路已停用(上游定聚已下线)。
- // 保留接口避免小程序端报错,统一返回提示;如需恢复请改走 QytOil。
- return $this->renderError('该功能已停用');
+ // 企油通给的是 H5 免登链接,没有二维码图片接口。
+ // 本商城跑在 H5(微信内置浏览器)下,走的是 qrCode + webview;
+ // 本方法只在微信小程序环境下被调用,暂不支持。
+ return $this->renderError('请在微信或浏览器中打开加油链接');
}
public function getBase64($url){
diff --git a/server-tp6/app/api/controller/QytNotify.php b/server-tp6/app/api/controller/QytNotify.php
index caf0887..1f70e1b 100644
--- a/server-tp6/app/api/controller/QytNotify.php
+++ b/server-tp6/app/api/controller/QytNotify.php
@@ -2,8 +2,13 @@
// +----------------------------------------------------------------------
// | 企油通 · 回调接收
// +----------------------------------------------------------------------
-// | 入口:public/qytconsume.php -> /qytNotify/consume
-// | 对应企油通 7.10「运单加油明细推送(异步)」,我方须返回 {"code":"100","msg":"Success"}
+// | 入口:
+// | public/qytconsume.php -> /qytNotify/consume 企油通 7.10「运单加油明细推送」
+// | public/qytwaybill.php -> /qytNotify/waybill 企油通 3.10「发放结果通知」
+// |
+// | 两个回调都是 multipart/form-data,业务参数在表单字段里(JSON 字符串):
+// | 7.10 -> params 我方须返回 {"code":"100","msg":"Success"}
+// | 3.10 -> data + sign 我方只要返回 HTTP 2xx 即算成功,响应体不做要求
// +----------------------------------------------------------------------
declare (strict_types=1);
@@ -70,4 +75,78 @@ class QytNotify extends Controller
return json(['code' => '100', 'msg' => 'Success']);
}
+
+ /**
+ * 3.10 发放结果通知(运单油费 / 企业福利)
+ *
+ * 企油通把 7.7 提交的批次处理完后(全部成功 / 部分成功 / 处理失败)主动推过来,
+ * 一个批次一条,内容与 7.8 查询结果一致。接上之后就不必再靠轮询等结果。
+ *
+ * 约定(见文档 3.10):
+ * · form-data,字段 data(JSON 字符串)+ sign
+ * · scene 区分场景:oil=运单油费下发(7.7),welfare=员工福利下发(9.3)
+ * · 接收端返回 HTTP 2xx 即视为成功;非 2xx 或 5 秒内无响应会重试,最多 5 次
+ * · 同一批次可能收到多次(重试 / 批次重新提交),必须幂等
+ * · 明细最多约 1 万条,报文可能很大
+ *
+ * 所以这里只做「尽快收下、丢进队列、立刻回 200」,真正的状态跃迁交给
+ * callback:queue 异步做,避免大报文处理超过上游 5 秒超时导致无谓重试。
+ *
+ * @return \think\response\Json
+ */
+ public function waybill()
+ {
+ $request = Request::instance();
+ // ⚠️ 必须直接读 $_POST:cores\Request 给所有入参挂了全局过滤器
+ // ['my_trim','my_htmlspecialchars','filter_emoji'],走 $request->post() 取到的
+ // JSON 引号会被转义成 ",json_decode 必然失败(通知会被静默丢掉)
+ $formData = isset($_POST['data']) ? trim((string) $_POST['data']) : '';
+ $sign = isset($_POST['sign']) ? trim((string) $_POST['sign']) : '';
+ $raw = $request->getContent();
+
+ $payload = $formData !== '' ? $formData : $raw;
+ $qyt = new QytOil();
+ // 明细可能上万条,日志只留头部,完整报文靠 batch_no 去企油通后台对
+ $qyt->saveLog('3.10 发放结果通知原文[' . $request->contentType() . '](截断):'
+ . mb_substr($payload, 0, 4000) . (mb_strlen($payload) > 4000 ? ' …(已截断)' : ''));
+
+ $data = json_decode($payload, true);
+ // 兜底:若上游或中间层做过 HTML 转义,反转义后再解析一次
+ if (!is_array($data) && strpos($payload, '"') !== false) {
+ $data = json_decode(htmlspecialchars_decode($payload, ENT_QUOTES), true);
+ }
+ // 兼容:直接推 JSON body 且外层包了一层 {data: ..., sign: ...}
+ if (is_array($data) && isset($data['data'])) {
+ $sign === '' && $sign = (string) ($data['sign'] ?? '');
+ $data = is_string($data['data']) ? json_decode($data['data'], true) : $data['data'];
+ }
+
+ if (!is_array($data) || empty($data['batch_no'])) {
+ $qyt->saveLog('3.10 发放结果通知解析失败或缺少 batch_no,已忽略');
+ // 仍然回 2xx:报文我方解析不了,让上游重试 5 次也没有意义,
+ // 这类单由 7.8 轮询兜底(轮询并没有下线)
+ return json(['code' => '100', 'msg' => 'Success']);
+ }
+
+ // TODO 验签(对 data 原串签名,规则见开发指南 2.5,待对接人给到平台公钥与算法确认)
+ // if ($sign !== '' && !(new QytCrypto())->verifyNotify($payload, $sign)) {
+ // $qyt->saveLog('3.10 发放结果通知验签失败 batch_no=' . $data['batch_no']);
+ // return json(['code' => '99', 'msg' => 'sign error']);
+ // }
+
+ // 只处理运单油费(7.7)。员工福利(9.3)我方没有对接,收下回 2xx 即可,
+ // 否则上游会按失败重试 5 次。
+ $scene = (string) ($data['scene'] ?? 'oil');
+ if ($scene !== 'oil') {
+ $qyt->saveLog('3.10 发放结果通知 scene=' . $scene . '(非运单油费),已忽略 batch_no=' . $data['batch_no']);
+ return json(['code' => '100', 'msg' => 'Success']);
+ }
+
+ (new QueueService())->push('callback_queue', json_encode([
+ 'header' => ['method' => 'qyt_waybill_result'],
+ 'res' => $data,
+ ], 320));
+
+ return json(['code' => '100', 'msg' => 'Success']);
+ }
}
diff --git a/server-tp6/app/api/controller/User.php b/server-tp6/app/api/controller/User.php
index be398f5..9f8c691 100644
--- a/server-tp6/app/api/controller/User.php
+++ b/server-tp6/app/api/controller/User.php
@@ -17,6 +17,7 @@ use app\api\model\User as UserModel;
use app\api\model\UserCoupon as UserCouponModel;
use app\api\service\User as UserService;
use cores\exception\BaseException;
+use think\facade\Db;
/**
* 用户管理
@@ -76,6 +77,46 @@ class User extends Controller
return $this->renderSuccess('恭喜您,手机号绑定成功');
}
+ /**
+ * 实名信息(姓名 / 身份证号 / 车牌号)
+ *
+ * 分油实名制:上游分油前做「姓名 + 身份证号」二要素认证,所以下单前必须补全。
+ * 车牌号只在本地留存与后台展示,不上送上游。
+ * @return Json
+ * @throws BaseException
+ */
+ public function realname(): Json
+ {
+ $userInfo = UserService::getCurrentLoginUser(true);
+ return $this->renderSuccess([
+ 'realname' => [
+ 'realName' => (string)$userInfo['real_name'],
+ 'idCard' => (string)$userInfo['id_card'],
+ 'plateNo' => (string)$userInfo['plate_no'],
+ // 身份证号已用于分油后不允许再改(上游按身份证号累计司机额度)
+ 'idCardLocked' => (string)$userInfo['id_card'] !== '' && Db::name('partners_order')
+ ->where('user_id', '=', (int)$userInfo['user_id'])
+ ->where('source', '=', 'mall')
+ ->count() > 0,
+ 'isReady' => UserModel::isRealnameReady($userInfo),
+ ]
+ ]);
+ }
+
+ /**
+ * 保存实名信息
+ * @return Json
+ * @throws BaseException
+ */
+ public function saveRealname(): Json
+ {
+ $model = new UserModel;
+ if (!$model->realname($this->postForm())) {
+ return $this->renderError($model->getError() ?: '保存失败');
+ }
+ return $this->renderSuccess('保存成功');
+ }
+
/**
* 修改个人信息(头像昵称)
* @return Json
diff --git a/server-tp6/app/api/model/User.php b/server-tp6/app/api/model/User.php
index ebbd0ce..6d62fe9 100644
--- a/server-tp6/app/api/model/User.php
+++ b/server-tp6/app/api/model/User.php
@@ -16,6 +16,7 @@ use app\common\enum\user\balanceLog\Scene as SceneEnum;
use app\common\library\helper;
use app\store\model\user\BalanceLog as BalanceLogModel;
use think\facade\Cache;
+use think\facade\Db;
use app\api\service\User as UserService;
use app\api\model\UserOauth as UserOauthModel;
use app\common\model\User as UserModel;
@@ -112,6 +113,75 @@ class User extends UserModel
return $userInfo->save($data);
}
+ /**
+ * 保存实名信息(姓名 / 身份证号 / 车牌号)
+ *
+ * 分油是实名制的:上游在分油前会做「姓名 + 身份证号」二要素认证,
+ * 所以这三项必须在下单前补全。车牌号只在本地留存与后台展示,不上送上游。
+ *
+ * @param array $form
+ * @return bool
+ * @throws BaseException
+ */
+ public function realname(array $form): bool
+ {
+ $realName = trim((string)($form['realName'] ?? ''));
+ $idCard = strtoupper(trim((string)($form['idCard'] ?? '')));
+ $plateNo = strtoupper(trim((string)($form['plateNo'] ?? '')));
+
+ if ($realName === '') {
+ $this->error = '请填写真实姓名';
+ return false;
+ }
+ // 中文姓名 2~20 字,允许少数民族姓名里的间隔号
+ if (!preg_match('/^[\x{4e00}-\x{9fa5}][\x{4e00}-\x{9fa5}·]{1,19}$/u', $realName)) {
+ $this->error = '姓名格式不正确,请填写身份证上的中文姓名';
+ return false;
+ }
+ if (!preg_match('/^\d{17}[\dX]$/', $idCard)) {
+ $this->error = '身份证号格式不正确(18位)';
+ return false;
+ }
+ // 省份简称 + 城市字母(不含 I/O,避免与 1/0 混淆) + 5~7 位序号(含新能源 8 位车牌)
+ if (!preg_match('/^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领][A-HJ-NP-Z][A-HJ-NP-Z0-9]{4,6}$/u', $plateNo)) {
+ $this->error = '车牌号格式不正确';
+ return false;
+ }
+
+ // 当前登录的用户信息
+ $userInfo = UserService::getCurrentLoginUser(true);
+ // 身份证号一旦用于分油就不允许再改:上游按身份证号累计司机额度,
+ // 改了会导致历史订单的额度与司机对不上。姓名/车牌可以改。
+ if ((string)$userInfo['id_card'] !== '' && (string)$userInfo['id_card'] !== $idCard) {
+ $used = Db::name('partners_order')
+ ->where('user_id', '=', (int)$userInfo['user_id'])
+ ->where('source', '=', 'mall')
+ ->count();
+ if ($used > 0) {
+ $this->error = '身份证号已用于分油,不能修改,请联系客服';
+ return false;
+ }
+ }
+
+ return $userInfo->save([
+ 'real_name' => $realName,
+ 'id_card' => $idCard,
+ 'plate_no' => $plateNo,
+ ]) !== false;
+ }
+
+ /**
+ * 实名信息是否已补全(下单前的必要条件)
+ * @param array|static $user
+ * @return bool
+ */
+ public static function isRealnameReady($user): bool
+ {
+ return trim((string)($user['real_name'] ?? '')) !== ''
+ && trim((string)($user['id_card'] ?? '')) !== ''
+ && trim((string)($user['plate_no'] ?? '')) !== '';
+ }
+
/**
* 验证绑定的手机号
* @param array $data
diff --git a/server-tp6/app/api/service/order/Checkout.php b/server-tp6/app/api/service/order/Checkout.php
index 9f1f440..afc9754 100644
--- a/server-tp6/app/api/service/order/Checkout.php
+++ b/server-tp6/app/api/service/order/Checkout.php
@@ -370,9 +370,30 @@ class Checkout extends BaseService
'balance' => $this->user['balance'],
'points' => $this->user['points'],
'address_id' => $this->user['address_id'],
+ // 实名信息(分油实名制,下单前必须补全):结算页据此拦截并跳去补全
+ 'real_name' => (string)$this->user['real_name'],
+ 'id_card' => (string)$this->user['id_card'],
+ 'plate_no' => (string)$this->user['plate_no'],
+ 'isRealnameReady' => UserModel::isRealnameReady($this->user),
+ 'hasOilGoods' => $this->hasOilGoods(),
+ 'needRealname' => $this->hasOilGoods() && !UserModel::isRealnameReady($this->user),
];
}
+ /**
+ * 本次结算是否包含油券商品(oilCardType = zsy_ticket)
+ * 油券要走企油通分油,必须有姓名/身份证号/车牌号
+ */
+ private function hasOilGoods(): bool
+ {
+ foreach ($this->goodsList as $goods) {
+ if (($goods['oilCardType'] ?? '') === 'zsy_ticket') {
+ return true;
+ }
+ }
+ return false;
+ }
+
/**
* 当前用户可用的优惠券列表
* @param float $orderTotalPrice 总金额
@@ -757,6 +778,12 @@ class Checkout extends BaseService
return false;
}
}
+ // 油券商品要走企油通分油(实名制:上游做姓名+身份证号二要素认证),
+ // 下单前必须补全实名信息。结算页已拦截,这里是服务端兜底。
+ if ($this->hasOilGoods() && !UserModel::isRealnameReady($this->user)) {
+ $this->error = '请先完善姓名、身份证号和车牌号后再下单';
+ return false;
+ }
return true;
}
diff --git a/server-tp6/app/api/service/order/PaySuccess.php b/server-tp6/app/api/service/order/PaySuccess.php
index 07ca7d7..c8e8f16 100644
--- a/server-tp6/app/api/service/order/PaySuccess.php
+++ b/server-tp6/app/api/service/order/PaySuccess.php
@@ -18,6 +18,7 @@ use app\common\lock\RedisLock;
use app\common\model\Coupon;
use app\common\service\SybOil;
use app\common\service\IdingjuOil;
+use app\common\service\MallOil;
use app\store\model\Company as CompanyModel;
use app\store\model\Coupon as CouponModel;
use app\store\model\GoodsCoupon as GoodsCouponModel;
@@ -108,15 +109,6 @@ class PaySuccess extends BaseService
}
// 获取订单详情
$detail = OrderModel::getUserOrderDetail((int)$this->model['order_id']);
- // 触发分油接口
- $shareStatus = $this->shareOil($detail);
- if (!$shareStatus) {
- // 记录日志
- log_record(['name' => '分油失败', [
- 'order_id' => $this->model['order_id'],
- ]]);
- // return false;
- }
// 事务处理
$this->model->transaction(function () use ($payType, $payData, $detail) {
// 更新订单状态
@@ -126,6 +118,16 @@ class PaySuccess extends BaseService
// 记录订单支付信息
$this->updatePayInfo($payType);
});
+ // 触发分油/发券(放在事务之后:企油通发放是真金白银,必须等订单确实置为已支付再发,
+ // 否则事务回滚会出现「订单未支付但油费已下发」)
+ $shareStatus = $this->shareOil($detail);
+ if (!$shareStatus) {
+ // 记录日志;不影响支付成功的结果,发放失败由 7.8 判定后自动退回用户余额
+ log_record(['name' => '分油失败', [
+ 'order_id' => $this->model['order_id'],
+ 'error' => $this->error,
+ ]]);
+ }
return true;
}
@@ -250,62 +252,30 @@ class PaySuccess extends BaseService
}
}
+ /**
+ * 支付成功后的商品发放
+ * · 油券(zsy_ticket) -> 建企油通发放单,由 partners:queue 调 7.7 下发
+ * · 优惠券(coupon) -> 直接发券(原逻辑不变)
+ *
+ * 返回 false 只用于写一条「分油失败」日志,不阻断支付成功:用户已经付过款,
+ * 订单必须正常置为已支付。发放失败由 7.8 判定后自动退回用户余额,或后台「重新发放」。
+ */
private function shareOil($order): bool
{
- $IdingjuOil = new IdingjuOil();
+ $ok = true;
+ // 下单用户(含实名信息:姓名/身份证号/车牌号,结算时已校验必填)
+ $user = Db::name('user')->where('user_id', (int)$order['user_id'])->find() ?: [];
foreach ($order->goods as $goods) {
/* @var GoodsModel $goods */
- // 企油通改造:小程序油品发放链路已停用,支付成功后不再触发上游分油(保留优惠券发放)。
- // 如需恢复小程序侧发放,改走 QytOil 并补齐司机三要素来源。
if ($goods['oilCardType'] == 'zsy_ticket') {
- continue;
- }
- if($goods['oilCardType'] == 'zsy_ticket') {
- $data['amount'] = $goods['total_price']; // 金额
- $company = CompanyModel::detail(['orgNo' => $this->getCompany($goods['goods_no'])]);
- if (!$company) {
- $this->error = '没有可用参数';
- return false;
+ // 企油通:一个油券商品行建一条发放单,金额取该行实付金额
+ $res = MallOil::create($order->toArray(), $goods->toArray(), $user);
+ if (!$res['ok']) {
+ $this->error = $res['message'];
+ $goods->save(['oil_text' => mb_substr($res['message'], 0, 190)]);
+ $ok = false;
}
- try {
- // 查找一个未被调用的用户
- $account = $this->processUserOrders($company['company_id']);
- } catch (\Throwable $e) {
- $this->error = $e->getMessage();
- return false;
- }
-
- // 查询公司余额
- /*if ($data['amount'] > $company['balance']) {
- $this->error = '公司余额不足';
- return false;
- }*/
- // 对接平台接口
- $res = $IdingjuOil->oilOilCardShare([
- 'goodsNo'=>$goods['goods_no'],
- 'amount'=>$data['amount'],
- 'userNo'=>$account['userNo'],
- 'mobile'=>$account['mobile'],
- 'saleOrderNo'=>$goods['oilCardNo'],
- 'oilCardType'=>$goods['oilCardType']
- ], $company);
- //$res = json_decode('{"code":"00","message":"请求成功","errCode":null,"errMessage":null,"data":{"oilCardNo":null,"amount":100,"balance":null,"shareNo":"CDV2408021928160001"}}', true);
- if ($res['code'] != 1) {
- $this->error = ($res['errMessage'] ?? $res['message']);
- return false;
- }
-
- $item['account_id'] = $account['account_id'];
- $item['userNo'] = $account['userNo'];
- $item['mobile'] = $account['mobile'];
- $item['company_id'] = $company['company_id'];
- $item['shareNo'] = $res['data']['trade_no'] ?? '';
- $item['qrCode'] = $res['data']['useUrl'] ?? '';
- $item['oil_status_zsy'] = $res['data']['state'] ?? 0;
- $item['oil_time'] = time();
- $item['oil_status'] = $item['oil_status_zsy'] == 2 ? 1 : 0; // 已发券 为1
- $goods->save($item);
- } elseif($goods['oilCardType'] == 'coupon'){
+ } elseif ($goods['oilCardType'] == 'coupon') {
// 发放优惠券
$coupons = GoodsCouponModel::getCouponList($goods['goods_id'])->toArray();
if($coupons && is_array($coupons)) {
@@ -319,7 +289,7 @@ class PaySuccess extends BaseService
}
}
}
- return true;
+ return $ok;
}
/**
diff --git a/server-tp6/app/common.php b/server-tp6/app/common.php
index 102c9d4..f955f9f 100644
--- a/server-tp6/app/common.php
+++ b/server-tp6/app/common.php
@@ -200,6 +200,26 @@ function runtime_root_path(): string
return dirname(runtime_path()) . DIRECTORY_SEPARATOR;
}
+/**
+ * 上游对接日志(storelog)的统一目录
+ *
+ * ⚠️ 不能用 runtime_path():多应用模式下 think-multi-app 会把运行时目录改写成
+ * runtime/{应用名}/(vendor/topthink/think-multi-app/src/MultiApp.php::setApp 里的
+ * setRuntimePath),于是同一条业务链路的日志会散到三个地方:
+ * CLI 常驻队列进程 runtime/storelog/
+ * api 入口(回调接收) runtime/api/storelog/
+ * store 后台操作 runtime/store/storelog/
+ * 排查时极易找错目录(比如回调明明收到了,却在 CLI 那个目录里怎么都 grep 不到)。
+ * 这里按项目根目录固定,所有进程写同一处。
+ *
+ * 注:改动只影响新日志,历史日志仍留在原来的三个目录里。
+ * @return string 带结尾分隔符的绝对路径
+ */
+function store_log_path(): string
+{
+ return app()->getRootPath() . 'runtime' . DIRECTORY_SEPARATOR . 'storelog' . DIRECTORY_SEPARATOR;
+}
+
/**
* 写入日志 (使用tp自带驱动记录到runtime目录中)
* @param mixed $value
diff --git a/server-tp6/app/common/model/Goods.php b/server-tp6/app/common/model/Goods.php
index d6d9dbb..985936e 100644
--- a/server-tp6/app/common/model/Goods.php
+++ b/server-tp6/app/common/model/Goods.php
@@ -237,15 +237,10 @@ class Goods extends BaseModel
$query->join($GoodsCategoryRelName, "{$GoodsCategoryRelName}.goods_id = {$this->name}.goods_id");
// 设置分类ID条件
$query->where('goods_category_rel.category_id', '=', (int)$params['categoryId']);
- }elseif(!empty($params['province']) && $params['location'] != 1){
- // 前端请求,没有分类则不展示商品
-
- // 关联商品与分类关系记录表
- $GoodsCategoryRelName = (new GoodsCategoryRelModel)->getName();
- $query->join($GoodsCategoryRelName, "{$GoodsCategoryRelName}.goods_id = {$this->name}.goods_id");
- // 设置分类ID条件
- $query->where('goods_category_rel.category_id', '=', -1);
}
+ // 原先这里有一条「带了 province 又没带分类 -> 强制返回空列表」的分支,
+ // 配合首页地图选城市使用。商品不再按省份强制过滤,该分支已移除:
+ // 没传 categoryId 就是查全部商品。
// 商品名称
!empty($params['goodsName']) && $filter[] = ['goods_name', 'like', "%{$params['goodsName']}%"];
// 商品编码
diff --git a/server-tp6/app/common/model/store/Setting.php b/server-tp6/app/common/model/store/Setting.php
index c4f8745..b40dd6f 100644
--- a/server-tp6/app/common/model/store/Setting.php
+++ b/server-tp6/app/common/model/store/Setting.php
@@ -328,6 +328,8 @@ class Setting extends BaseModel
'h5_base_url' => '', // 7.15 落地页
'h5_app_id' => '', // 7.15 免登 appId
'consume_notify_url' => '', // 7.10 回调地址(仅记录,实际在企油通后台配)
+ 'waybill_notify_url' => '', // 3.10 发放结果通知地址(同上)
+ 'waybill_notify' => 0, // 3.10 发放结果通知是否已生效(开 -> 7.8 轮询退为安全网)
'log' => 1, // 是否记录详细请求/响应日志
],
],
diff --git a/server-tp6/app/common/service/IdingjuOil.php b/server-tp6/app/common/service/IdingjuOil.php
index e975080..40c72d2 100644
--- a/server-tp6/app/common/service/IdingjuOil.php
+++ b/server-tp6/app/common/service/IdingjuOil.php
@@ -35,8 +35,8 @@ class IdingjuOil extends BaseService
public function saveLogStore($data){
$years = date('Ym');
$day = date('Ymd');
- //设置路径目录信息
- $dir = runtime_path('storelog/').$years.'/'.$day;
+ //设置路径目录信息(store_log_path 保证 CLI / api / store 各进程写同一处,见 app/common.php)
+ $dir = store_log_path().$years.'/'.$day;
//目录不存在就创建
$this->mk_dir_store($dir);
$url = $dir.'/'.date('YmdH').'.txt';
diff --git a/server-tp6/app/common/service/MallOil.php b/server-tp6/app/common/service/MallOil.php
new file mode 100644
index 0000000..a5fd2eb
--- /dev/null
+++ b/server-tp6/app/common/service/MallOil.php
@@ -0,0 +1,390 @@
+ 企油通发放
+// +----------------------------------------------------------------------
+// | 设计:小程序订单不另起一套企油通链路,而是在支付成功后为每个油券商品行
+// | 建一条 yoshop_partners_order(source='mall'),复用现有机器:
+// | · partners:queue 7.7 运单油费下发
+// | · qyt:status 7.8 轮询发放结果
+// | · 7.10 消费回调 按 waybill_no_code 匹配,无需改动
+// | · QytRefund 7.17 查凭证 + 7.16 调低额度
+// | · QytInvoice 7.14 企业发票
+// | · QytDriver 司机档案
+// |
+// | 两种来源的差异只有三处,全部收敛在本类:
+// | 1. 额度:合作商单扣/补 partners.balance;商城单用户已付款,改退用户余额
+// | 2. 通知:合作商单 HTTP 回调下单平台;商城单没有下游,改回写 order_goods
+// | 3. 建单:合作商单由开放接口 OilCard::share 建;商城单由 PaySuccess 建
+// +----------------------------------------------------------------------
+declare (strict_types=1);
+
+namespace app\common\service;
+
+use app\common\enum\user\balanceLog\Scene as SceneEnum;
+use app\common\model\qyt\Enterprise as EnterpriseModel;
+use think\facade\Db;
+
+class MallOil
+{
+ /** partners_order.source 取值 */
+ const SOURCE_MALL = 'mall';
+ const SOURCE_PARTNER = 'partner';
+
+ /**
+ * 是否小程序(商城)单
+ * @param array|\think\Model $order partners_order 记录
+ * @return bool
+ */
+ public static function isMall($order): bool
+ {
+ return (string) ($order['source'] ?? self::SOURCE_PARTNER) === self::SOURCE_MALL;
+ }
+
+ // ================================================================
+ // 建单(支付成功后调用)
+ // ================================================================
+
+ /**
+ * 为一个油券商品行创建企油通发放单并投递 partners_queue
+ *
+ * 幂等:复用已有唯一键 uk_partner_saleorder(partners_id, partners_order_no),
+ * mall 行 partners_id=0、partners_order_no='M{订单号}-{order_goods_id}'。
+ * 支付回调重入时唯一键冲突 -> 当作「已建过」返回原单,绝不重复发放。
+ *
+ * @param array $order 商城订单(yoshop_order)
+ * @param array $goods 订单商品行(yoshop_order_goods)
+ * @param array $user 下单用户(含 real_name / id_card / plate_no)
+ * @return array ['ok' => bool, 'message' => string, 'order_id' => int]
+ */
+ public static function create(array $order, array $goods, array $user): array
+ {
+ // 发放金额按油券面值(数量 x 单价),与改造前的口径一致;
+ // 用户实付(优惠券/积分抵扣后)另存 total_pay_price,退款回补余额时按它折算。
+ $amount = round((float) $goods['total_price'], 2);
+ $paid = round((float) $goods['total_pay_price'], 2);
+ $paid > $amount && $paid = $amount;
+ if ($amount <= 0) {
+ return self::fail('商品金额为 0,不发放');
+ }
+
+ $mobile = trim((string) ($user['mobile'] ?? ''));
+ $name = trim((string) ($user['real_name'] ?? ''));
+ $idCard = strtoupper(trim((string) ($user['id_card'] ?? '')));
+ $plateNo = strtoupper(trim((string) ($user['plate_no'] ?? '')));
+
+ // 两个支付通道都要求实名,缺一不可。结算时已拦截,这里是第二道闸。
+ $missing = [];
+ $mobile === '' && $missing[] = '手机号';
+ $name === '' && $missing[] = '姓名';
+ $idCard === '' && $missing[] = '身份证号';
+ if ($missing) {
+ return self::fail('缺' . implode('/', $missing) . ',无法发放');
+ }
+
+ // 本单使用的企油通企业:小程序没有合作商绑定关系,直接取默认企业
+ $enterprise = EnterpriseModel::resolve(0);
+ if (!$enterprise) {
+ return self::fail('系统未配置可用的企油通企业,无法发放');
+ }
+
+ $saleOrderNo = 'M' . $order['order_no'] . '-' . $goods['order_goods_id'];
+ $exist = Db::name('partners_order')
+ ->where('partners_id', 0)
+ ->where('partners_order_no', $saleOrderNo)
+ ->find();
+ if ($exist) {
+ // 已建过(支付回调重入):直接复用,并补投一次队列(下发是条件跃迁,重复投递安全)
+ self::push((int) $exist['order_id']);
+ return ['ok' => true, 'message' => '已存在发放单', 'order_id' => (int) $exist['order_id']];
+ }
+
+ $now = time();
+ // 带上 order_goods_id(全局唯一)保证油卡编号不会撞号
+ $oilCardNo = 'OIL' . $now . 'G' . (int) $goods['order_goods_id'];
+ $row = [
+ 'province' => (string) ($goods['province'] ?? ''),
+ 'mobile' => $mobile,
+ 'driver_name' => $name,
+ 'id_card' => $idCard,
+ 'plate_no' => $plateNo,
+ // 企业与通道随单固化:之后切默认企业,在途单仍按下单时的企业发放
+ 'qyt_enterprise_id' => (int) $enterprise['enterprise_id'],
+ 'passageway' => (string) $enterprise['passageway'],
+ 'oilCardType' => 'zsy_ticket',
+ 'oilCardNo' => $oilCardNo,
+ 'partners_order_no' => $saleOrderNo,
+ 'partners_id' => 0,
+ 'source' => self::SOURCE_MALL,
+ 'mall_order_id' => (int) $order['order_id'],
+ 'mall_order_goods_id' => (int) $goods['order_goods_id'],
+ 'user_id' => (int) $order['user_id'],
+ 'goods_id' => (int) $goods['goods_id'],
+ 'goods_name' => (string) $goods['goods_name'],
+ 'goods_no' => (string) $goods['goods_no'],
+ 'goods_price' => $amount,
+ 'total_num' => 1,
+ 'total_price' => $amount,
+ 'total_pay_price' => $paid,
+ 'order_status' => 10,
+ 'store_id' => (int) $order['store_id'],
+ 'create_time' => $now,
+ 'update_time' => $now,
+ ];
+
+ try {
+ $orderId = (int) Db::name('partners_order')->insertGetId($row);
+ } catch (\Throwable $e) {
+ if (strpos($e->getMessage(), 'Duplicate') !== false) {
+ $again = Db::name('partners_order')->where('partners_id', 0)
+ ->where('partners_order_no', $saleOrderNo)->find();
+ if ($again) {
+ self::push((int) $again['order_id']);
+ return ['ok' => true, 'message' => '已存在发放单', 'order_id' => (int) $again['order_id']];
+ }
+ }
+ self::log('商城单建发放单失败 order_goods=' . $goods['order_goods_id'] . ':' . $e->getMessage());
+ return self::fail('建发放单失败:' . $e->getMessage());
+ }
+
+ // 把油卡编号写回订单商品行,小程序端与后台基础列继续按原字段展示
+ Db::name('order_goods')->where('order_goods_id', (int) $goods['order_goods_id'])->update([
+ 'oilCardNo' => $oilCardNo,
+ 'mobile' => $mobile,
+ 'oil_text' => '已提交发放',
+ ]);
+
+ // 司机档案(按身份证号归档并标记信息冲突;内部吞异常,不影响主流程)
+ QytDriver::touch([
+ 'order_id' => $orderId,
+ 'id_card' => $idCard,
+ 'driver_name' => $name,
+ 'mobile' => $mobile,
+ 'store_id' => (int) $order['store_id'],
+ ]);
+
+ self::push($orderId);
+ return ['ok' => true, 'message' => '已提交发放', 'order_id' => $orderId];
+ }
+
+ // ================================================================
+ // 额度回补(发放失败 / 退款成功)
+ // ================================================================
+
+ /**
+ * 按订单来源把钱退回原处:
+ * · 合作商单 -> 回补 partners.balance
+ * · 商城单 -> 回补下单用户余额并记一条余额流水
+ * 只在状态跃迁胜出后调用,天然只执行一次。
+ *
+ * @param array|\think\Model $order partners_order 记录
+ * @param float $amount 金额(元)
+ * @param string $scene 场景描述,写进流水
+ */
+ public static function giveBack($order, float $amount, string $scene = '发放失败'): void
+ {
+ if ($amount <= 0) {
+ return;
+ }
+ if (!self::isMall($order)) {
+ PartnersBalance::refund(
+ (int) $order['partners_id'],
+ $amount,
+ (string) $order['partners_order_no'],
+ (int) $order['order_id'],
+ (int) $order['store_id']
+ );
+ self::log("{$scene}已回补合作商余额 order={$order['order_id']} amount={$amount}");
+ return;
+ }
+
+ $userId = (int) ($order['user_id'] ?? 0);
+ if ($userId <= 0) {
+ self::log("【告警】商城单缺少 user_id,无法退款 order={$order['order_id']} amount={$amount}");
+ return;
+ }
+ // 退回余额按「用户实付 / 油券面值」折算:用过优惠券或积分抵扣时,
+ // 面值大于实付,按面值原额退会白送钱。全额发放失败时折算结果正好等于实付。
+ $amount = self::toPaid($order, $amount);
+ if ($amount <= 0) {
+ return;
+ }
+ $money = self::money($amount);
+ try {
+ Db::transaction(function () use ($userId, $money, $amount, $order, $scene) {
+ Db::name('user')->where('user_id', $userId)
+ ->update(['balance' => Db::raw('`balance` + ' . $money)]);
+ Db::name('user_balance_log')->insert([
+ 'user_id' => $userId,
+ 'scene' => SceneEnum::REFUND,
+ 'money' => round($amount, 2),
+ 'describe' => $scene . ':' . ($order['oilCardNo'] ?: $order['order_id']),
+ 'remark' => '',
+ 'store_id' => (int) $order['store_id'],
+ 'create_time' => time(),
+ ]);
+ });
+ self::log("{$scene}已退回用户余额 order={$order['order_id']} user={$userId} amount={$amount}");
+ } catch (\Throwable $e) {
+ // 退款失败必须人工介入:额度那边可能已经收回了
+ self::log("【告警】商城单退回用户余额失败 order={$order['order_id']} user={$userId}"
+ . " amount={$amount}:" . $e->getMessage());
+ }
+ }
+
+ /**
+ * 扣减额度(后台「重新发放」用)
+ * 发放失败时额度已原路退回,重发必须重新扣一次:
+ * · 合作商单 -> 扣 partners.balance
+ * · 商城单 -> 扣下单用户余额(按实付折算)
+ * 两者都是 UPDATE ... WHERE balance >= amount,天然防并发超扣。
+ *
+ * @return bool false=余额不足,未做任何变更
+ */
+ public static function take($order, float $amount, string $scene = '重新发放'): bool
+ {
+ if ($amount <= 0) {
+ return true;
+ }
+ if (!self::isMall($order)) {
+ $logId = PartnersBalance::deduct(
+ (int) $order['partners_id'],
+ $amount,
+ (string) $order['partners_order_no'],
+ (int) $order['order_id'],
+ (int) $order['store_id']
+ );
+ return $logId !== false;
+ }
+
+ $userId = (int) ($order['user_id'] ?? 0);
+ if ($userId <= 0) {
+ return false;
+ }
+ $amount = self::toPaid($order, $amount);
+ if ($amount <= 0) {
+ return true;
+ }
+ $money = self::money($amount);
+ $affected = Db::name('user')->where('user_id', $userId)
+ ->where('balance', '>=', $amount)
+ ->update(['balance' => Db::raw('`balance` - ' . $money)]);
+ if (!$affected) {
+ return false;
+ }
+ try {
+ Db::name('user_balance_log')->insert([
+ 'user_id' => $userId,
+ 'scene' => SceneEnum::CONSUME,
+ 'money' => -$amount,
+ 'describe' => $scene . ':' . ($order['oilCardNo'] ?: $order['order_id']),
+ 'remark' => '',
+ 'store_id' => (int) $order['store_id'],
+ 'create_time' => time(),
+ ]);
+ } catch (\Throwable $e) {
+ self::log('商城单扣款流水写入失败 order=' . $order['order_id'] . ':' . $e->getMessage());
+ }
+ return true;
+ }
+
+ /** 额度不足时的提示文案 */
+ public static function notEnoughText($order): string
+ {
+ return self::isMall($order)
+ ? '下单用户余额不足(发放失败时已退回其余额),无法重新发放'
+ : '合作商余额不足,请先充值后再重发';
+ }
+
+ // ================================================================
+ // 状态回写(代替合作商单的 HTTP 回调)
+ // ================================================================
+
+ /**
+ * 把发放单的状态同步回 yoshop_order_goods。
+ * 小程序端的订单列表/详情、后台订单页的基础列都读 order_goods,
+ * 所以商城单不走 HTTP 通知,改为在同样的时机回写这张表。
+ *
+ * @param array|\think\Model $order partners_order 记录
+ * @param string $optType share_oil / add_oil / refund
+ */
+ public static function syncGoods($order, string $optType = ''): void
+ {
+ $goodsId = (int) ($order['mall_order_goods_id'] ?? 0);
+ if (!self::isMall($order) || $goodsId <= 0) {
+ return;
+ }
+ try {
+ // 锁内重读发放单:调用方可能持有的是跃迁前的旧快照
+ $fresh = Db::name('partners_order')->where('order_id', (int) $order['order_id'])->find();
+ if (!$fresh) {
+ return;
+ }
+ $data = [
+ 'oil_status' => (int) $fresh['oil_status'],
+ 'oil_status_zsy' => (int) $fresh['oil_status_zsy'],
+ 'oil_time' => (int) $fresh['oil_time'],
+ 'oil_text' => mb_substr((string) $fresh['oil_text'], 0, 190),
+ 'shareNo' => (string) $fresh['shareNo'],
+ 'use_status' => (int) $fresh['use_status'],
+ 'use_time' => (int) $fresh['use_time'],
+ 'use_text' => mb_substr((string) $fresh['use_text'], 0, 190),
+ // 一单可分多次加油:把累计值带到订单商品行,小程序端据此算剩余可加油金额
+ 'used_amount' => (float) $fresh['used_amount'],
+ 'used_num' => (int) $fresh['used_num'],
+ 'refund_status' => (int) $fresh['refund_status'],
+ 'refund_amount' => (float) $fresh['refund_amount'],
+ ];
+ // 发放失败时额度已原路退回用户余额,把这句一起告诉用户,省掉一轮客服询问
+ if ((int) $fresh['oil_status_zsy'] === 3) {
+ $data['oil_text'] = mb_substr($fresh['oil_text'] . ',款项已退回账户余额', 0, 190);
+ }
+ // 已退款的单在小程序端不应再显示「可核销」
+ if ((int) $fresh['refund_status'] === 2) {
+ $data['oil_text'] = mb_substr('已退款:' . $fresh['refund_text'], 0, 190);
+ }
+ Db::name('order_goods')->where('order_goods_id', $goodsId)->update($data);
+ } catch (\Throwable $e) {
+ self::log('商城单回写 order_goods 失败 order=' . $order['order_id'] . ':' . $e->getMessage());
+ }
+ }
+
+ // ================================================================
+ // 内部
+ // ================================================================
+
+ /**
+ * 把「油券面值金额」折算成「用户实付金额」
+ * 面值 total_price 与实付 total_pay_price 在用过优惠券/积分时不相等。
+ */
+ protected static function toPaid($order, float $amount): float
+ {
+ $face = (float) ($order['total_price'] ?? 0);
+ $paid = (float) ($order['total_pay_price'] ?? 0);
+ if ($face <= 0 || $paid >= $face) {
+ return round($amount, 2);
+ }
+ return round(min($amount * $paid / $face, $paid), 2);
+ }
+
+ /** 投递下发队列 */
+ protected static function push(int $orderId): void
+ {
+ (new QueueService())->push('partners_queue', json_encode(['order_id' => $orderId], 320));
+ }
+
+ protected static function fail(string $message): array
+ {
+ return ['ok' => false, 'message' => $message, 'order_id' => 0];
+ }
+
+ /** 金额安全格式化(仅数字与小数点,防拼接注入) */
+ protected static function money(float $amount): string
+ {
+ return number_format(round($amount, 2), 2, '.', '');
+ }
+
+ protected static function log(string $text): void
+ {
+ (new QytOil())->saveLog('[商城单] ' . $text);
+ }
+}
diff --git a/server-tp6/app/common/service/PartnersOil.php b/server-tp6/app/common/service/PartnersOil.php
index e4ba290..2df2bd9 100644
--- a/server-tp6/app/common/service/PartnersOil.php
+++ b/server-tp6/app/common/service/PartnersOil.php
@@ -35,8 +35,8 @@ class PartnersOil extends BaseService
public function saveLogStore($data){
$years = date('Ym');
$day = date('Ymd');
- //设置路径目录信息
- $dir = runtime_path('storelog/').$years.'/'.$day;
+ //设置路径目录信息(store_log_path 保证 CLI / api / store 各进程写同一处,见 app/common.php)
+ $dir = store_log_path().$years.'/'.$day;
//目录不存在就创建
$this->mk_dir_store($dir);
$url = $dir.'/'.date('YmdH').'.txt';
diff --git a/server-tp6/app/common/service/QueueService.php b/server-tp6/app/common/service/QueueService.php
index 60f122a..3c2307f 100644
--- a/server-tp6/app/common/service/QueueService.php
+++ b/server-tp6/app/common/service/QueueService.php
@@ -29,6 +29,31 @@ class QueueService
return $message ? json_decode($message, true) : null;
}
+ /**
+ * 阻塞式取出消息:队列空时挂在 Redis 上等,有消息立刻返回。
+ *
+ * 相比 pop() + sleep(1) 的轮询,取到消息的延迟从「最多 1 秒」降到接近 0,
+ * 对「下单 -> 调上游发放」这种链路首段能实打实省掉一截等待。
+ *
+ * @param string $queueName
+ * @param int $timeout 最长阻塞秒数(到点返回 null,便于外层做兜底巡检)
+ * @return array|string|null
+ */
+ public function bpop($queueName, $timeout = 5)
+ {
+ // BLPOP 会把连接挂住 $timeout 秒,必须短于 Redis 连接的读超时
+ // (未显式配置时取 PHP default_socket_timeout,通常 60 秒),
+ // 否则会抛读超时异常而不是正常返回 null。这里夹到 30 秒以内。
+ $timeout = max(1, min(30, (int) $timeout));
+ // Predis 的 blpop 返回 [队列名, 消息],超时返回 null
+ $res = $this->redis->blpop([$queueName], $timeout);
+ if (empty($res[1])) {
+ return null;
+ }
+ $decoded = json_decode($res[1], true);
+ return $decoded === null ? $res[1] : $decoded;
+ }
+
/**
* 延迟推送(ZSET)
* @param string $queueName
diff --git a/server-tp6/app/common/service/QytConfig.php b/server-tp6/app/common/service/QytConfig.php
index a277dc3..2a052a6 100644
--- a/server-tp6/app/common/service/QytConfig.php
+++ b/server-tp6/app/common/service/QytConfig.php
@@ -29,7 +29,7 @@ class QytConfig
/** 后台设置可覆盖的接入方级标量项 */
const OVERRIDABLE = [
'base_url', 'app_id', 'no_prefix', 'voucher_name',
- 'h5_base_url', 'h5_app_id', 'consume_notify_url',
+ 'h5_base_url', 'h5_app_id', 'consume_notify_url', 'waybill_notify_url',
];
/** @var array|null */
@@ -55,10 +55,13 @@ class QytConfig
$conf[$k] = trim((string) $db[$k]);
}
}
- // 日志开关是布尔,0 是合法值,不能用非空判断
+ // 布尔项 0 是合法值,不能用非空判断
if (isset($db['log'])) {
$conf['log'] = (bool) $db['log'];
}
+ if (isset($db['waybill_notify'])) {
+ $conf['waybill_notify'] = (bool) $db['waybill_notify'];
+ }
self::$cache = $conf;
self::$cacheAt = time();
diff --git a/server-tp6/app/common/service/QytOil.php b/server-tp6/app/common/service/QytOil.php
index 486d26a..f09a037 100644
--- a/server-tp6/app/common/service/QytOil.php
+++ b/server-tp6/app/common/service/QytOil.php
@@ -550,13 +550,13 @@ class QytOil extends BaseService
return bcdiv((string) (int) $fen, '100', 2);
}
- /** 记录日志到 runtime/storelog */
+ /** 记录日志到 runtime/storelog(store_log_path 保证各进程写同一处,见 app/common.php) */
public function saveLog($data): void
{
if (empty($this->conf['log'])) {
return;
}
- $dir = runtime_path('storelog/') . date('Ym') . '/' . date('Ymd');
+ $dir = store_log_path() . date('Ym') . '/' . date('Ymd');
is_dir($dir) || @mkdir($dir, 0777, true);
@file_put_contents(
$dir . '/qyt_' . date('YmdH') . '.txt',
diff --git a/server-tp6/app/common/service/QytRefund.php b/server-tp6/app/common/service/QytRefund.php
index b2e114f..6f0ba69 100644
--- a/server-tp6/app/common/service/QytRefund.php
+++ b/server-tp6/app/common/service/QytRefund.php
@@ -403,10 +403,11 @@ class QytRefund
'response' => self::dumpRes($res),
'update_time' => time(),
]);
- // 只有状态跃迁胜出者才回补合作商余额,保证只退一次
+ // 只有状态跃迁胜出者才原路退款,保证只退一次
+ // · 合作商单 -> 回补 partners.balance
+ // · 商城单 -> 退回下单用户余额(按实付折算)
if ($won) {
- PartnersBalance::refund((int) $order['partners_id'], $amount, (string) $order['partners_order_no'],
- (int) $order['order_id'], (int) $order['store_id']);
+ MallOil::giveBack($order, $amount, '订单退款');
}
});
@@ -422,7 +423,8 @@ class QytRefund
'optType' => 'refund',
'retry_count' => 0,
], 320));
- (new QytOil())->saveLog("退款成功 order={$order['order_id']} refund_no={$refundNo} amount={$amount},已回补合作商余额");
+ MallOil::syncGoods($order, 'refund');
+ (new QytOil())->saveLog("退款成功 order={$order['order_id']} refund_no={$refundNo} amount={$amount},已原路退款");
}
$order['refund_status'] = self::SUCCESS;
$order['refund_amount'] = $amount;
diff --git a/server-tp6/app/common/service/QytWaybill.php b/server-tp6/app/common/service/QytWaybill.php
new file mode 100644
index 0000000..11ebd78
--- /dev/null
+++ b/server-tp6/app/common/service/QytWaybill.php
@@ -0,0 +1,207 @@
+ 根本不推(且不视为失败、不补推)
+// | 2. 推送失败重试 5 次后放弃(文档建议此时用 7.8 查)
+// | 3. 我们自己的接收端宕机 / 发版重启窗口期
+// | 所以轮询保留为安全网,只是间隔放长(见 QytStatusQueue::intervalSql)
+// |
+// | 并发/幂等:
+// | 状态跃迁是条件 UPDATE(... WHERE oil_status_zsy=1),affected=1 才退款/回调,
+// | 因此「同一批次重复推送」「推送与轮询同时到达」都只会生效一次。
+// +----------------------------------------------------------------------
+declare (strict_types=1);
+
+namespace app\common\service;
+
+use think\facade\Db;
+
+class QytWaybill
+{
+ // 明细级 handle_status(7.8 出参 / 3.10 通知 data.list[].handle_status)
+ const HANDLE_PENDING = 1;
+ const HANDLE_SUCCESS = 2;
+ const HANDLE_FAILED = 3;
+
+ // 批次级 status(7.8 出参 data.status / 3.10 通知 data.status)
+ const BATCH_PENDING = 1; // 待处理
+ const BATCH_RUNNING = 2; // 处理中
+ const BATCH_ALL_OK = 3; // 全部成功
+ const BATCH_PART_OK = 4; // 部分成功
+ const BATCH_FAILED = 5; // 处理失败
+
+ // judge() 的结论
+ const R_SUCCESS = 'success';
+ const R_FAILED = 'failed';
+ const R_PENDING = 'pending';
+
+ /**
+ * 纯函数:根据批次级 status 与本条明细,判定这一单的最终发放结果。
+ *
+ * 文档 3.10 明确:**以批次级 status 为准**——批次因「企业信息不存在」之类
+ * 整体失败时,明细里可能仍然写着「待处理」。只看 handle_status 会让这种单
+ * 永远停在发放中,钱也永远不退。
+ *
+ * @param int $batchStatus 批次级 status
+ * @param array|null $row 本条明细 ['handle_status','error_msg','remark'],不存在传 null
+ * @return array ['result' => success|failed|pending, 'text' => string]
+ */
+ public static function judge(int $batchStatus, ?array $row): array
+ {
+ $handle = $row ? (int) ($row['handle_status'] ?? 0) : 0;
+
+ // 1) 整批处理失败:不管明细写的是什么,这一单都拿不到钱
+ if ($batchStatus === self::BATCH_FAILED && $handle !== self::HANDLE_SUCCESS) {
+ $msg = $row ? trim((string) ($row['error_msg'] ?? '')) : '';
+ return ['result' => self::R_FAILED, 'text' => $msg !== '' ? $msg : '企油通返回整批处理失败'];
+ }
+ // 2) 明细里没有这一条:批次还在排队,继续等
+ if (!$row) {
+ return ['result' => self::R_PENDING, 'text' => ''];
+ }
+ if ($handle === self::HANDLE_SUCCESS) {
+ return ['result' => self::R_SUCCESS, 'text' => trim((string) ($row['remark'] ?? '')) ?: '发放成功'];
+ }
+ if ($handle === self::HANDLE_FAILED) {
+ return ['result' => self::R_FAILED, 'text' => trim((string) ($row['error_msg'] ?? '')) ?: '企油通发放失败'];
+ }
+ // 1 待处理,或未知值 -> 继续等
+ return ['result' => self::R_PENDING, 'text' => ''];
+ }
+
+ /**
+ * 从 3.10 通知或 7.8 响应的 data.list 里挑出某条运单流水
+ * @return array|null ['handle_status','error_msg','remark']
+ */
+ public static function pick(array $data, string $waybillNoCode): ?array
+ {
+ return QytOil::pickWaybill($data, $waybillNoCode);
+ }
+
+ /**
+ * 发放成功:条件跃迁 1 -> 2,胜出者才回调下游 / 同步小程序端
+ *
+ * @param array $order partners_order 记录
+ * @param string $text 备注(上游 remark)
+ * @param string $from 结果来源,只用于日志:'3.10 通知' / '7.8 轮询'
+ * @return bool 是否由本次调用完成跃迁
+ */
+ public static function markSuccess(array $order, string $text, string $from = ''): bool
+ {
+ $affected = Db::name('partners_order')
+ ->where('order_id', $order['order_id'])
+ ->where('oil_status_zsy', 1)
+ ->update([
+ 'oil_status' => 1,
+ 'oil_status_zsy' => 2,
+ 'oil_time' => time(),
+ 'oil_text' => mb_substr($text, 0, 190),
+ 'update_time' => time(),
+ ]);
+ if (!$affected) {
+ return false;
+ }
+ MallOil::syncGoods($order, 'share_oil');
+ (new QueueService())->push('notify_queue', json_encode([
+ 'order_id' => $order['order_id'],
+ 'type' => 'add',
+ 'optType' => 'share_oil',
+ 'retry_count' => 0,
+ ], 320));
+ self::log("{$from} 确认发放成功 order={$order['order_id']} batch_no={$order['batch_no']}");
+ return true;
+ }
+
+ /**
+ * 发放失败:条件跃迁 1 -> 3,胜出者才退款(保证只退一次)
+ *
+ * @return bool 是否由本次调用完成跃迁
+ */
+ public static function markFailed(array $order, string $reason, string $from = ''): bool
+ {
+ $affected = Db::name('partners_order')
+ ->where('order_id', $order['order_id'])
+ ->where('oil_status_zsy', 1)
+ ->update([
+ 'oil_status_zsy' => 3,
+ 'oil_text' => mb_substr('发放失败:' . $reason, 0, 190),
+ 'update_time' => time(),
+ ]);
+ if (!$affected) {
+ return false;
+ }
+ // 原路退款:合作商单回补合作商余额,商城单退回下单用户余额
+ MallOil::giveBack($order, (float) $order['total_price'], '发放失败');
+ MallOil::syncGoods($order, 'share_oil');
+ self::log("{$from} 确认发放失败 order={$order['order_id']} 原因={$reason},已原路退款 {$order['total_price']}");
+ return true;
+ }
+
+ /**
+ * 按判定结果落状态。已经不在「发放中」的单会被安全忽略,
+ * 但「已判失败又被告知成功」属于资损风险,必须告警而不是静默翻转。
+ *
+ * @return string 实际发生的动作:success / failed / pending / skipped / conflict
+ */
+ public static function apply(array $order, array $judged, string $from = ''): string
+ {
+ $current = (int) $order['oil_status_zsy'];
+
+ if ($judged['result'] === self::R_PENDING) {
+ return self::R_PENDING;
+ }
+
+ if ($current !== 1) {
+ // 终态与上游结论不一致 -> 一律只告警、不自动翻转:
+ // 3 -> 成功:我们已经退过款,钱却可能真发出去了(资损)
+ // 2 -> 失败:我们已经通知下游发放成功、小程序端也显示可加油了
+ // 两种都要人工到企油通后台核对,自动改状态只会把问题放大。
+ $conflict = ($current === 3 && $judged['result'] === self::R_SUCCESS)
+ || ($current === 2 && $judged['result'] === self::R_FAILED);
+ if ($conflict) {
+ self::log("【告警】order={$order['order_id']} batch_no={$order['batch_no']} 本地发放态为「"
+ . ($current === 3 ? '发放失败(已退款)' : '发放成功(已通知下游)')
+ . "」,但{$from}返回「" . ($judged['result'] === self::R_SUCCESS ? '发放成功' : '发放失败')
+ . "」:{$judged['text']}。请人工到企油通后台核对,系统不会自动翻转状态。");
+ return 'conflict';
+ }
+ return 'skipped';
+ }
+
+ if ($judged['result'] === self::R_SUCCESS) {
+ return self::markSuccess($order, $judged['text'], $from) ? self::R_SUCCESS : 'skipped';
+ }
+ return self::markFailed($order, $judged['text'], $from) ? self::R_FAILED : 'skipped';
+ }
+
+ /**
+ * 记录本次通知时间与次数,便于排查「这单到底推没推过、推了几次」。
+ * 纯留痕,失败不能影响发放态跃迁(比如迁移脚本还没跑、少了这两列)。
+ */
+ public static function touchNotify(int $orderId, int $notifyTime): void
+ {
+ try {
+ Db::name('partners_order')->where('order_id', $orderId)->update([
+ 'qyt_notify_time' => $notifyTime ?: time(),
+ 'qyt_notify_num' => Db::raw('qyt_notify_num + 1'),
+ ]);
+ } catch (\Throwable $e) {
+ self::log('3.10 通知留痕失败 order=' . $orderId . ':' . $e->getMessage()
+ . '(不影响发放态,确认 qyt_migration_waybill_notify.sql 是否已执行)');
+ }
+ }
+
+ protected static function log(string $text): void
+ {
+ (new QytOil())->saveLog($text);
+ }
+}
diff --git a/server-tp6/app/common/service/SybOil.php b/server-tp6/app/common/service/SybOil.php
index 30d7dbc..a2e64f4 100644
--- a/server-tp6/app/common/service/SybOil.php
+++ b/server-tp6/app/common/service/SybOil.php
@@ -35,8 +35,8 @@ class SybOil extends BaseService
public function saveLogStore($data){
$years = date('Ym');
$day = date('Ymd');
- //设置路径目录信息
- $dir = runtime_path('storelog/').$years.'/'.$day;
+ //设置路径目录信息(store_log_path 保证 CLI / api / store 各进程写同一处,见 app/common.php)
+ $dir = store_log_path().$years.'/'.$day;
//目录不存在就创建
$this->mk_dir_store($dir);
$url = $dir.'/'.date('YmdH').'.txt';
diff --git a/server-tp6/app/console/command/CallbackQueue.php b/server-tp6/app/console/command/CallbackQueue.php
index feea7f7..165e1a1 100644
--- a/server-tp6/app/console/command/CallbackQueue.php
+++ b/server-tp6/app/console/command/CallbackQueue.php
@@ -8,6 +8,8 @@ use think\console\Input;
use think\console\Output;
use app\store\model\order\Record as RecordModel;
use app\common\service\SybOil;
+use app\common\service\QytWaybill;
+use app\common\lock\RedisLock;
use think\facade\Db;
class CallbackQueue extends Command
@@ -427,6 +429,58 @@ class CallbackQueue extends Command
$SybOil->saveLogStore('企油通消费回调处理成功:' . json_encode($c, 320));
}
break;
+ case "qyt_waybill_result":
+ // 企油通 3.10「发放结果通知」:一个批次一条,整批明细都在 res.list 里。
+ // 判定与状态跃迁跟 7.8 轮询共用 QytWaybill,保证两条路结论一致、互相幂等。
+ $batchNo = (string)($res['batch_no'] ?? '');
+ $batchStatus = (int)($res['status'] ?? 0);
+ $notifyTime = !empty($res['notify_time']) ? (strtotime((string)$res['notify_time']) ?: time()) : time();
+ $list = (array)($res['list'] ?? []);
+ $stat = ['success' => 0, 'failed' => 0, 'pending' => 0, 'skipped' => 0, 'conflict' => 0, 'miss' => 0];
+
+ foreach ($list as $row) {
+ $code = trim((string)($row['waybill_no_code'] ?? ''));
+ if ($code === '') {
+ $stat['miss']++;
+ continue;
+ }
+ $order = Db::name('partners_order')->where('waybill_no_code', $code)->find();
+ if (!$order) {
+ // 同一个 appId 下可能还挂着别的接入系统提交的批次,匹配不到不算异常
+ $stat['miss']++;
+ continue;
+ }
+ // 留痕:这一单到底收没收到过推送、收了几次
+ QytWaybill::touchNotify((int)$order['order_id'], $notifyTime);
+
+ // 每单串行,避免与 qyt:status 轮询同时跃迁
+ $lock = new RedisLock('qyt:status:' . $order['order_id'], 60);
+ if (!$lock->acquireLock()) {
+ // 轮询正在处理这一单,跳过即可:两条路的结论一样
+ $stat['skipped']++;
+ continue;
+ }
+ try {
+ // 锁内重读,拿到最新状态
+ $fresh = Db::name('partners_order')->where('order_id', $order['order_id'])->find();
+ $judged = QytWaybill::judge($batchStatus, [
+ 'handle_status' => (int)($row['handle_status'] ?? 0),
+ 'error_msg' => (string)($row['error_msg'] ?? ''),
+ 'remark' => (string)($row['remark'] ?? ''),
+ ]);
+ $stat[QytWaybill::apply($fresh ?: $order, $judged, '3.10 通知')]++;
+ } finally {
+ $lock->releaseLock();
+ }
+ }
+
+ $SybOil->saveLogStore('3.10 发放结果通知处理完成 batch_no=' . $batchNo
+ . ' 批次状态=' . $batchStatus . ' 明细=' . count($list)
+ . ' 成功=' . $stat['success'] . ' 失败=' . $stat['failed']
+ . ' 待处理=' . $stat['pending'] . ' 已是终态=' . $stat['skipped']
+ . ' 未匹配=' . $stat['miss']
+ . ($stat['conflict'] ? ' 【冲突=' . $stat['conflict'] . ',需人工核对】' : ''));
+ break;
default:
$SybOil->saveLogStore('队列数据处理异常,未知:【'.$message.'】');
}
diff --git a/server-tp6/app/console/command/NotifyQueue.php b/server-tp6/app/console/command/NotifyQueue.php
index 86dc1cc..f4fc7ad 100644
--- a/server-tp6/app/console/command/NotifyQueue.php
+++ b/server-tp6/app/console/command/NotifyQueue.php
@@ -1,6 +1,7 @@
where('id', (int)$data['consume_id'])
+ ->update(['notify_status' => 1, 'notify_time' => time()]);
+ }
+ return true;
+ }
$partners = partnersModel::detail($order['partners_id']);
if(!$partners){
return false;
diff --git a/server-tp6/app/console/command/PartnersQueue.php b/server-tp6/app/console/command/PartnersQueue.php
index 7322fa5..21f22b5 100644
--- a/server-tp6/app/console/command/PartnersQueue.php
+++ b/server-tp6/app/console/command/PartnersQueue.php
@@ -22,7 +22,7 @@ namespace app\console\command;
use app\common\lock\RedisLock;
use app\common\model\partners\Order as OrderModel;
use app\common\model\qyt\Enterprise as EnterpriseModel;
-use app\common\service\PartnersBalance;
+use app\common\service\MallOil;
use app\common\service\QueueService;
use app\common\service\QytOil;
use think\console\Command;
@@ -48,12 +48,13 @@ class PartnersQueue extends Command
{
$this->queueService = new QueueService();
while (true) {
- $message = $this->queueService->pop('partners_queue');
+ // 阻塞式取队列:有单立刻开始下发,不用等轮询间隔。
+ // 5 秒超时只是为了让空闲时也能跑到下面的兜底巡检(内部已限频 60s)。
+ $message = $this->queueService->bpop('partners_queue', 5);
if ($message) {
$this->processMessage($message);
} else {
$this->reconcile();
- sleep(1);
}
}
}
@@ -198,18 +199,14 @@ class PartnersQueue extends Command
}
/**
- * 发放失败 -> 回补合作商余额(仅在状态跃迁胜出时调用,天然幂等)
+ * 发放失败 -> 原路退款(仅在状态跃迁胜出时调用,天然幂等)
+ * · 合作商单 -> 回补 partners.balance
+ * · 商城单 -> 退回下单用户余额(按实付折算),并把失败状态回写 order_goods
*/
protected function refundBalance($order)
{
- PartnersBalance::refund(
- (int) $order['partners_id'],
- (float) $order['total_price'],
- (string) $order['partners_order_no'],
- (int) $order['order_id'],
- (int) $order['store_id']
- );
- (new QytOil())->saveLog("发放失败已回补合作商余额 order={$order['order_id']} amount={$order['total_price']}");
+ MallOil::giveBack($order, (float) $order['total_price'], '发放失败');
+ MallOil::syncGoods($order);
}
/**
diff --git a/server-tp6/app/console/command/QytStatusQueue.php b/server-tp6/app/console/command/QytStatusQueue.php
index da37853..c900f62 100644
--- a/server-tp6/app/console/command/QytStatusQueue.php
+++ b/server-tp6/app/console/command/QytStatusQueue.php
@@ -2,8 +2,15 @@
// +----------------------------------------------------------------------
// | 企油通「运单发放结果」轮询 (7.8 waybillBatch)
// +----------------------------------------------------------------------
-// | 为什么需要它:7.7 运单油费下发是【异步】接口,返回成功只代表受理,
-// | 真实发放结果必须按 batch_no 调 7.8 查(《企油通接口对接文档.md》§5 第 7 条)。
+// | 为什么需要它:7.7 运单油费下发是【异步】接口,返回成功只代表受理。
+// |
+// | 拿最终结果有两条路,判定逻辑共用 app\common\service\QytWaybill:
+// | · 推:3.10 发放结果通知 -> public/qytwaybill.php -> callback:queue(主路径)
+// | · 拉:本命令按 batch_no 调 7.8 查询(安全网)
+// |
+// | 接上推送后本命令不会下线:上游未配置接收地址不推送、推送重试 5 次后放弃、
+// | 我方接收端宕机或发版重启,这三种情况都只能靠轮询兜底。此时退避间隔会自动放长,
+// | 见 intervalSql()。
// |
// | 状态流转(oil_status_zsy):
// | 0 未提交 --PartnersQueue 调7.7受理--> 1 发放中 --本命令查7.8--> 2 成功 / 3 失败
@@ -27,9 +34,9 @@ namespace app\console\command;
use app\common\lock\RedisLock;
use app\common\model\qyt\Enterprise as EnterpriseModel;
-use app\common\service\PartnersBalance;
-use app\common\service\QueueService;
+use app\common\service\QytConfig;
use app\common\service\QytOil;
+use app\common\service\QytWaybill;
use think\console\Command;
use think\console\Input;
use think\console\Output;
@@ -40,15 +47,12 @@ class QytStatusQueue extends Command
/** 每轮扫描的最大订单数 */
const BATCH_LIMIT = 100;
- /** 空转时的休眠秒数 */
- const IDLE_SLEEP = 5;
+ /** 空转时的休眠秒数(只是一条轻量 SQL,不打上游,可以压到 1 秒) */
+ const IDLE_SLEEP = 1;
/** 查询次数达到该值后仍未定论,视为异常滞留,写告警日志 */
const STUCK_NUM = 100;
- /** @var QueueService */
- protected $queueService;
-
protected function configure()
{
$this->setName('qyt:status')
@@ -57,7 +61,7 @@ class QytStatusQueue extends Command
protected function execute(Input $input, Output $output)
{
- $this->queueService = new QueueService();
+ // 通知下游 / 同步小程序端都在 QytWaybill 里做,这里只负责查结果
while (true) {
$handled = 0;
try {
@@ -94,11 +98,38 @@ class QytStatusQueue extends Command
/**
* 退避间隔(秒)的 SQL 表达式:查得越久间隔越长,避免高频打上游
- * 前 10 次 30s,10~30 次 120s,之后 600s
+ *
+ * 为什么前几次要这么密:7.7 受理后第一次 7.8 几乎必然返回「待处理」,
+ * 如果第二次要等 30 秒,哪怕企油通第 8 秒就发完了,用户也得等到第 35 秒。
+ * 绝大多数单在前 20 秒内就有结果,所以把这一段压到 3 秒一次,
+ * 代价是每单多打上游几次;拖久的单自动退到长间隔,总 QPS 不会失控。
+ *
+ * 注意:若已接入 3.10 发放结果通知(后台「企油通对接设置」里打开开关),
+ * 走的是上面那条「安全网」曲线,不是下面这条抢时间的曲线。
+ *
+ * 次数 <6 -> 3s (覆盖 0~18s,正常单在这里出结果)
+ * 次数 <16 -> 10s (覆盖至约 2 分钟)
+ * 次数 <26 -> 30s (覆盖至约 7 分钟)
+ * 次数 <46 -> 120s (覆盖至约 47 分钟)
+ * 之后 -> 600s
*/
protected function intervalSql(): string
{
- return 'CASE WHEN qyt_query_num < 10 THEN 30 WHEN qyt_query_num < 30 THEN 120 ELSE 600 END';
+ // 接上 3.10 发放结果通知后,结果以推送为主,轮询退为安全网:
+ // 只覆盖「上游没配接收地址 / 推送重试 5 次仍失败 / 我方接收端当时不可用」
+ // 这三种情况,没必要再抢在推送前面把上游打一遍。
+ if (!empty(QytConfig::get()['waybill_notify'])) {
+ return 'CASE'
+ . ' WHEN qyt_query_num < 3 THEN 60'
+ . ' WHEN qyt_query_num < 8 THEN 300'
+ . ' ELSE 900 END';
+ }
+ return 'CASE'
+ . ' WHEN qyt_query_num < 6 THEN 3'
+ . ' WHEN qyt_query_num < 16 THEN 10'
+ . ' WHEN qyt_query_num < 26 THEN 30'
+ . ' WHEN qyt_query_num < 46 THEN 120'
+ . ' ELSE 600 END';
}
/**
@@ -143,31 +174,16 @@ class QytStatusQueue extends Command
return;
}
+ // 判定与状态跃迁跟 3.10 推送共用 QytWaybill,两条路结论必须一致
$batchStatus = (int) ($res['data']['status'] ?? 0);
- $row = QytOil::pickWaybill($res['data'], (string) $order['waybill_no_code']);
+ $row = QytWaybill::pick($res['data'], (string) $order['waybill_no_code']);
+ $judged = QytWaybill::judge($batchStatus, $row);
- // 整批处理失败,且明细里没有本条 -> 判失败
- if (!$row && $batchStatus === 5) {
- $this->markFailed($order, '企油通返回整批处理失败', $qyt);
- return;
- }
- // 明细里还没有本条:批次还在排队,继续等
- if (!$row) {
+ if ($judged['result'] === QytWaybill::R_PENDING) {
$this->alertIfStuck($order, $qyt);
return;
}
-
- switch ($row['handle_status']) {
- case 2: // 成功
- $this->markSuccess($order, $row['remark'] ?: '发放成功', $qyt);
- break;
- case 3: // 失败
- $this->markFailed($order, $row['error_msg'] ?: '企油通发放失败', $qyt);
- break;
- default: // 1 待处理,或未知值 -> 继续等
- $this->alertIfStuck($order, $qyt);
- break;
- }
+ QytWaybill::apply($order, $judged, '7.8 轮询');
} catch (\Throwable $e) {
$this->log("order={$orderId} 轮询异常:" . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
} finally {
@@ -175,60 +191,6 @@ class QytStatusQueue extends Command
}
}
- /**
- * 发放成功:条件跃迁 1 -> 2,胜出者才回调下单平台
- */
- protected function markSuccess(array $order, string $text, QytOil $qyt): void
- {
- $affected = Db::name('partners_order')
- ->where('order_id', $order['order_id'])
- ->where('oil_status_zsy', 1)
- ->update([
- 'oil_status' => 1,
- 'oil_status_zsy' => 2,
- 'oil_time' => time(),
- 'oil_text' => $text,
- 'update_time' => time(),
- ]);
- if (!$affected) {
- return;
- }
- $this->queueService->push('notify_queue', json_encode([
- 'order_id' => $order['order_id'],
- 'type' => 'add',
- 'optType' => 'share_oil',
- 'retry_count' => 0,
- ], 320));
- $qyt->saveLog("7.8 确认发放成功 order={$order['order_id']} batch_no={$order['batch_no']}"
- . " 查询次数={$order['qyt_query_num']}");
- }
-
- /**
- * 发放失败:条件跃迁 1 -> 3,胜出者才退款(保证只退一次)
- */
- protected function markFailed(array $order, string $reason, QytOil $qyt): void
- {
- $affected = Db::name('partners_order')
- ->where('order_id', $order['order_id'])
- ->where('oil_status_zsy', 1)
- ->update([
- 'oil_status_zsy' => 3,
- 'oil_text' => '发放失败:' . $reason,
- 'update_time' => time(),
- ]);
- if (!$affected) {
- return;
- }
- PartnersBalance::refund(
- (int) $order['partners_id'],
- (float) $order['total_price'],
- (string) $order['partners_order_no'],
- (int) $order['order_id'],
- (int) $order['store_id']
- );
- $qyt->saveLog("7.8 确认发放失败 order={$order['order_id']} 原因={$reason},已回补合作商余额 {$order['total_price']}");
- }
-
/**
* 长期拿不到结果时告警。只写日志,不改状态、不退款
*/
diff --git a/server-tp6/app/store/controller/Order.php b/server-tp6/app/store/controller/Order.php
index 019cbea..086b2c5 100644
--- a/server-tp6/app/store/controller/Order.php
+++ b/server-tp6/app/store/controller/Order.php
@@ -153,8 +153,10 @@ class Order extends Controller
*/
public function cancelShare(): Json
{
- // 企油通改造:本功能依赖已下线的上游(智慧油客/定聚),现已停用。
- return $this->renderError('小程序油品链路已停用');
+ // 企油通改造:小程序订单的发放/加油/退款统一走 yoshop_partners_order,
+ // 后台操作接口复用 app/store/controller/partners/Order.php(/partners.order/*),
+ // 前端订单页传的是商品行上挂的 qyt.order_id。本接口保留仅为兼容老前端。
+ return $this->renderError('该入口已迁移,请使用 /partners.order/ 下的同名接口');
}
/**
@@ -164,8 +166,10 @@ class Order extends Controller
*/
public function qrCode(): Json
{
- // 企油通改造:本功能依赖已下线的上游(智慧油客/定聚),现已停用。
- return $this->renderError('小程序油品链路已停用');
+ // 企油通改造:小程序订单的发放/加油/退款统一走 yoshop_partners_order,
+ // 后台操作接口复用 app/store/controller/partners/Order.php(/partners.order/*),
+ // 前端订单页传的是商品行上挂的 qyt.order_id。本接口保留仅为兼容老前端。
+ return $this->renderError('该入口已迁移,请使用 /partners.order/ 下的同名接口');
}
/**
@@ -250,7 +254,9 @@ class Order extends Controller
*/
public function share(): Json
{
- // 企油通改造:本功能依赖已下线的上游(智慧油客/定聚),现已停用。
- return $this->renderError('小程序油品链路已停用');
+ // 企油通改造:小程序订单的发放/加油/退款统一走 yoshop_partners_order,
+ // 后台操作接口复用 app/store/controller/partners/Order.php(/partners.order/*),
+ // 前端订单页传的是商品行上挂的 qyt.order_id。本接口保留仅为兼容老前端。
+ return $this->renderError('该入口已迁移,请使用 /partners.order/ 下的同名接口');
}
}
diff --git a/server-tp6/app/store/controller/partners/Order.php b/server-tp6/app/store/controller/partners/Order.php
index 61bd192..d6eff46 100644
--- a/server-tp6/app/store/controller/partners/Order.php
+++ b/server-tp6/app/store/controller/partners/Order.php
@@ -13,7 +13,7 @@ declare (strict_types=1);
namespace app\store\controller\partners;
-use app\common\service\PartnersBalance;
+use app\common\service\MallOil;
use app\common\service\QueueService;
use app\common\model\qyt\Enterprise as EnterpriseModel;
use app\common\service\QytInvoice;
@@ -119,16 +119,10 @@ class Order extends Controller
if ($order['use_status'] != 0) {
return $this->renderError('该单已加油核销,不可重发');
}
- // 发放失败时余额已回补,重发需重新扣减合作商余额
- $logId = PartnersBalance::deduct(
- (int) $order['partners_id'],
- (float) $order['total_price'],
- (string) $order['partners_order_no'],
- $orderId,
- (int) $order['store_id']
- );
- if ($logId === false) {
- return $this->renderError('合作商余额不足,请先充值后再重发');
+ // 发放失败时额度已原路退回,重发需重新扣减:
+ // 合作商单扣合作商余额,小程序单扣下单用户余额
+ if (!MallOil::take($order, (float) $order['total_price'])) {
+ return $this->renderError(MallOil::notEnoughText($order));
}
// 仅允许从「发放失败(3)」重置为「未提交(0)」,避免与「发放中(1)」的单冲突。
// 同时清空编号并把重发次数 +1:企油通按 batch_no 幂等,沿用老批次的话
@@ -148,17 +142,13 @@ class Order extends Controller
'update_time' => time(),
]);
if (!$reset) {
- // 状态已被其它进程改变 -> 回补刚扣的余额
- PartnersBalance::refund(
- (int) $order['partners_id'],
- (float) $order['total_price'],
- (string) $order['partners_order_no'],
- $orderId,
- (int) $order['store_id']
- );
+ // 状态已被其它进程改变 -> 回补刚扣的额度
+ MallOil::giveBack($order, (float) $order['total_price'], '重发撤销');
return $this->renderError('当前状态不允许重发(仅「发放失败」的订单可重发)');
}
(new QueueService())->push('partners_queue', json_encode(['order_id' => $orderId], 320));
+ // 小程序单把「重新发放」状态同步回订单商品行,用户端立刻能看到
+ MallOil::syncGoods($order);
return $this->renderSuccess('已提交重新发放,请稍后刷新查看结果');
}
diff --git a/server-tp6/app/store/model/Order.php b/server-tp6/app/store/model/Order.php
index 82c1e48..bc4214a 100644
--- a/server-tp6/app/store/model/Order.php
+++ b/server-tp6/app/store/model/Order.php
@@ -21,6 +21,7 @@ use think\facade\Request;
use app\common\model\Goods as GoodsModel;
use app\common\model\Order as OrderModel;
use app\common\service\Order as OrderService;
+use app\common\service\QytOil;
use app\common\service\Message as MessageService;
use app\common\service\order\Refund as RefundService;
use app\common\enum\order\OrderStatus as OrderStatusEnum;
@@ -121,6 +122,8 @@ class Order extends OrderModel
})
->order(['order.create_time' => 'desc'])
->paginate(10);
+ // 给每个油券商品行挂上对应的企油通发放单(后台订单页的企油通列与操作按钮用)
+ $this->setQytData($list);
// 获取数据列表
return [
'list' => $list,
@@ -128,6 +131,55 @@ class Order extends OrderModel
];
}
+ /**
+ * 给订单商品行挂上企油通发放单数据(字段名 qyt)
+ *
+ * 小程序订单在支付成功后由 MallOil::create 为每个油券商品行建了一条
+ * yoshop_partners_order(source='mall',mall_order_goods_id 指回商品行),
+ * 企油通的发放/加油/退款/发票全挂在那张表上。后台订单页要展示这些信息、
+ * 并复用 /partners.order/* 的操作接口,所以这里按商品行把发放单带出来。
+ *
+ * @param \think\Paginator|\think\Collection $list
+ */
+ public function setQytData($list): void
+ {
+ $goodsIds = [];
+ foreach ($list as $order) {
+ foreach ($order['goods'] as $goods) {
+ $goodsIds[] = (int)$goods['order_goods_id'];
+ }
+ }
+ if (!$goodsIds) {
+ return;
+ }
+ $rows = Db::name('partners_order')
+ ->where('source', '=', 'mall')
+ ->whereIn('mall_order_goods_id', $goodsIds)
+ ->select()->toArray();
+ $map = [];
+ foreach ($rows as $row) {
+ // 身份证号脱敏(后台展示层)
+ $row['id_card_mask'] = QytOil::maskIdCard((string)$row['id_card']);
+ unset($row['id_card']);
+ // 前端用的是严格相等(item.oil_status_zsy !== 2),这里显式转型,
+ // 不依赖 PDO 是否把 int 列取成字符串
+ foreach (['order_id', 'oil_status', 'oil_status_zsy', 'use_status', 'refund_status',
+ 'used_num', 'oil_callback', 'use_callback', 'qyt_enterprise_id',
+ 'mall_order_id', 'mall_order_goods_id', 'user_id'] as $k) {
+ $row[$k] = (int)$row[$k];
+ }
+ // 剩余可退金额 = 发放金额 - 已加油 - 已退款
+ $row['remain_amount'] = number_format(max(0, (float)$row['total_price']
+ - (float)$row['used_amount'] - (float)$row['refund_amount']), 2, '.', '');
+ $map[(int)$row['mall_order_goods_id']] = $row;
+ }
+ foreach ($list as $order) {
+ foreach ($order['goods'] as $goods) {
+ $goods['qyt'] = $map[(int)$goods['order_goods_id']] ?? null;
+ }
+ }
+ }
+
/**
* 订单列表(全部)
@@ -173,6 +225,8 @@ class Order extends OrderModel
'pay_status' => -1, // 支付状态
'oil_status' => -1, // 分油状态
'use_status' => -1, // 使用状态
+ 'oil_status_zsy' => -1, // 企油通发放态 0未提交 1发放中 2成功 3失败
+ 'refund_status' => -1, // 退款状态 0未退款 1退款中 2已退款 3退款失败 4待人工确认
'deliveryType' => -1, // 配送方式
'betweenTime' => [], // 起止时间
'userId' => 0, // 会员ID
@@ -233,6 +287,22 @@ class Order extends OrderModel
->column('order_id'); // 获取所有符合条件的 order_id 数组
$filter[] = ['order.order_id', 'in', $subQuery];
}
+ // 企油通发放态(挂在 partners_order 上,按 mall_order_id 反查)
+ if($params['oil_status_zsy'] > -1){
+ $subQuery = Db::name('partners_order')
+ ->where('source', '=', 'mall')
+ ->where('oil_status_zsy', '=', (int)$params['oil_status_zsy'])
+ ->column('mall_order_id');
+ $filter[] = ['order.order_id', 'in', $subQuery];
+ }
+ // 退款状态(同上)
+ if($params['refund_status'] > -1){
+ $subQuery = Db::name('partners_order')
+ ->where('source', '=', 'mall')
+ ->where('refund_status', '=', (int)$params['refund_status'])
+ ->column('mall_order_id');
+ $filter[] = ['order.order_id', 'in', $subQuery];
+ }
// 配送方式
$params['deliveryType'] > -1 && $filter[] = ['delivery_type', '=', (int)$params['deliveryType']];
// 用户id
diff --git a/server-tp6/app/store/model/partners/Order.php b/server-tp6/app/store/model/partners/Order.php
index c9faa84..2c07113 100644
--- a/server-tp6/app/store/model/partners/Order.php
+++ b/server-tp6/app/store/model/partners/Order.php
@@ -81,10 +81,13 @@ class Order extends OrderModel
'use_status' => -1, // 加油核销状态
'refund_status' => -1, // 退款状态 0未退款 1退款中 2已退款 3退款失败 4待人工确认
'id_card' => '', // 司机身份证号(从司机管理跳转过来时使用)
+ 'source' => 'partner', // 订单来源 partner=下单平台 mall=小程序商城 all=全部
'betweenTime' => [] // 起止时间
]);
// 检索查询条件
$filter = [];
+ // 来源:本页只管下单平台的单,小程序单在「订单管理」里看,默认过滤掉
+ $params['source'] !== 'all' && $filter[] = ['order.source', '=', (string)$params['source']];
// 合作商ID
$params['partners_id'] > 0 && $filter[] = ['order.partners_id', '=', $params['partners_id']];
// 司机手机号
@@ -150,8 +153,9 @@ class Order extends OrderModel
if(!empty($partners)) {
$filter[] = ['partners_id', '=', $partners['partners_id']];
}
- // 总销售额
+ // 总销售额(只统计下单平台的单,小程序单不计入合作商口径)
return $query->where('oil_status_zsy', '=', 2)
+ ->where('source', '=', 'partner')
->where($filter)
->sum('total_price');
}
diff --git a/server-tp6/config/qyt.php b/server-tp6/config/qyt.php
index 2b4cb95..b6d8387 100644
--- a/server-tp6/config/qyt.php
+++ b/server-tp6/config/qyt.php
@@ -56,6 +56,15 @@ return [
// 形如 https://你的域名/qytconsume.php
'consume_notify_url' => env('qyt.consume_notify_url', ''),
+ // ★ 我方对外回调地址(配置到企油通后台,用于 3.10 发放结果通知,scene=oil)
+ // 形如 https://你的域名/qytwaybill.php
+ 'waybill_notify_url' => env('qyt.waybill_notify_url', ''),
+
+ // 3.10 发放结果通知是否已在企油通后台配好并生效。
+ // 打开后 7.8 轮询自动退为「安全网」,间隔放长(见 QytStatusQueue::intervalSql);
+ // 关闭时按抢时间的密集曲线轮询。⚠️ 对方还没配地址就打开,会让分油变慢。
+ 'waybill_notify' => (bool) env('qyt.waybill_notify', false),
+
// 7.15 H5 免登落地页地址(测试 / 正式不同)
// 测试:http://dev.paytaskpage.worknoya.vip/#/ 正式:https://paytaskpage.worknoya.vip/#/
'h5_base_url' => env('qyt.h5_base_url', 'https://paytaskpage.worknoya.vip/#/'),
diff --git a/server-tp6/deploy/env-qyt.example b/server-tp6/deploy/env-qyt.example
index 4c34dc9..3ebd8bd 100644
--- a/server-tp6/deploy/env-qyt.example
+++ b/server-tp6/deploy/env-qyt.example
@@ -29,5 +29,10 @@ H5_BASE_URL = https://paytaskpage.worknoya.vip/#/
H5_APP_ID =
; 我方 7.10 回调地址(此项仅作记录,实际需在企油通后台配置)
CONSUME_NOTIFY_URL = https://www.hmzkw.com/qytconsume.php
+; 我方 3.10 发放结果通知地址(同上,需在企油通后台按 scene=oil 配置)
+WAYBILL_NOTIFY_URL = https://www.hmzkw.com/qytwaybill.php
+; 3.10 发放结果通知是否已生效:true -> 7.8 轮询退为安全网,间隔放长
+; ⚠️ 对方还没配好接收地址就设 true,分油结果要等轮询才拿得到,反而变慢
+WAYBILL_NOTIFY = false
; 是否记录详细请求/响应日志到 runtime/storelog
LOG = true
diff --git a/server-tp6/deploy/qyt_waybill_push.php b/server-tp6/deploy/qyt_waybill_push.php
new file mode 100644
index 0000000..e05b2ff
--- /dev/null
+++ b/server-tp6/deploy/qyt_waybill_push.php
@@ -0,0 +1,333 @@
+ 解析 ->
+// | 队列 -> 消费者」这条链路通不通,不碰数据库、不改任何订单。
+// | 预期结果是「未匹配=1」—— 那正是自检通过的标志。
+// | --order=订单ID 单推一条(推荐)
+// | --batch=批次号 按 batch_no 把该批次的单全推一遍
+// | --result=success|failed|pending 明细级 handle_status,默认 success
+// | --batch-status=3|4|5 批次级 status,默认按 result 推导(成功=3 失败=5)
+// | --msg=文案 成功时写 remark,失败时写 error_msg
+// | --url=通知地址 默认取 .env 的 QYT.WAYBILL_NOTIFY_URL
+// | --dry-run 只打印,不实际发送
+// |
+// | ⚠️ 这会真实推进订单发放态:判成功会通知下游 / 同步小程序端,判失败会原路退款。
+// | 只用于联调环境。联调结束请删除本文件。
+// +----------------------------------------------------------------------
+declare (strict_types=1);
+
+if (PHP_SAPI !== 'cli') {
+ http_response_code(403);
+ exit('仅限命令行执行');
+}
+
+$root = dirname(__DIR__);
+
+// ---------------- 参数解析 ----------------
+$opt = [];
+foreach (array_slice($argv, 1) as $arg) {
+ if (preg_match('/^--([a-zA-Z0-9\-_]+)(?:=(.*))?$/', $arg, $m)) {
+ $opt[str_replace('-', '_', $m[1])] = $m[2] ?? '1';
+ }
+}
+$fake = !empty($opt['fake']);
+$orderId = isset($opt['order']) ? (int) $opt['order'] : 0;
+$batchNo = isset($opt['batch']) ? trim((string) $opt['batch']) : '';
+$dryRun = !empty($opt['dry_run']);
+$result = isset($opt['result']) ? strtolower(trim((string) $opt['result'])) : 'success';
+$msg = isset($opt['msg']) ? (string) $opt['msg'] : '';
+
+if (!$fake && $orderId <= 0 && $batchNo === '') {
+ exit("用法:php deploy/qyt_waybill_push.php --fake # 不需要订单,自检链路\n"
+ . " php deploy/qyt_waybill_push.php --order=<订单ID> [--result=success|failed]\n"
+ . " php deploy/qyt_waybill_push.php --batch=<批次号>\n"
+ . "详细参数见本文件头部注释。\n");
+}
+if (!in_array($result, ['success', 'failed', 'pending'], true)) {
+ exit("--result 只能是 success / failed / pending\n");
+}
+
+// 明细级 handle_status:1 待处理 / 2 成功 / 3 失败
+$handleStatus = ['success' => 2, 'failed' => 3, 'pending' => 1][$result];
+// 批次级 status:3 全部成功 / 4 部分成功 / 5 处理失败
+$batchStatus = isset($opt['batch_status'])
+ ? (int) $opt['batch_status']
+ : ($result === 'success' ? 3 : ($result === 'failed' ? 5 : 2));
+
+// ---------------- 读取 .env ----------------
+$ini = @parse_ini_file($root . '/.env', true, INI_SCANNER_RAW) ?: [];
+$db = $ini['DATABASE'] ?? [];
+$qyt = $ini['QYT'] ?? [];
+$url = isset($opt['url']) ? (string) $opt['url'] : trim((string) ($qyt['WAYBILL_NOTIFY_URL'] ?? ''));
+if ($url === '') {
+ $url = 'http://127.0.0.1/qytwaybill.php';
+}
+
+// ---------------- 取订单(自检模式跳过) ----------------
+$pdo = null;
+$orders = [];
+if ($fake) {
+ // 造一条绝不会命中任何订单的运单流水号:
+ // 服务端应当「收下 -> 入队 -> 消费者判为未匹配」,整条链路都走到,但不动任何数据
+ $batchNo === '' && $batchNo = 'SELFCHECK' . date('YmdHis');
+ $fakeCode = 'SELFCHECKCODE' . date('YmdHis') . mt_rand(100, 999);
+} else {
+try {
+ $pdo = new PDO(
+ 'mysql:host=' . ($db['HOSTNAME'] ?? '127.0.0.1') . ';port=' . ($db['HOSTPORT'] ?? '3306')
+ . ';dbname=' . ($db['DATABASE'] ?? 'oil') . ';charset=' . ($db['CHARSET'] ?? 'utf8'),
+ $db['USERNAME'] ?? 'root', $db['PASSWORD'] ?? '',
+ [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
+ );
+} catch (Throwable $e) {
+ exit("连不上数据库:{$e->getMessage()}\n(本脚本需要读订单的 batch_no / waybill_no_code)\n");
+}
+
+if ($orderId > 0) {
+ $st = $pdo->prepare('SELECT * FROM yoshop_partners_order WHERE order_id = ? LIMIT 1');
+ $st->execute([$orderId]);
+ $orders = array_filter([$st->fetch() ?: null]);
+ if (!$orders) {
+ exit("未找到订单 order_id={$orderId}\n");
+ }
+} else {
+ $st = $pdo->prepare('SELECT * FROM yoshop_partners_order WHERE batch_no = ? ORDER BY order_id');
+ $st->execute([$batchNo]);
+ $orders = $st->fetchAll();
+ if (!$orders) {
+ exit("未找到批次 batch_no={$batchNo} 下的订单\n");
+ }
+}
+}
+
+// ---------------- 组装报文(字段与 3.10 文档一致) ----------------
+$list = [];
+$now = date('Y-m-d H:i:s');
+$i = 0;
+if ($fake) {
+ $list[] = [
+ 'id' => 1,
+ 'batch_no' => $batchNo,
+ 'waybill_no' => 'SELFCHECKWB' . date('YmdHis'),
+ 'waybill_no_code' => $fakeCode,
+ 'mobile' => '13800000000',
+ 'id_card' => '110101199001011234',
+ 'name' => '自检',
+ 'quota_amount' => 1,
+ 'voucher_name' => '运单卡',
+ 'effective_start' => '',
+ 'effective_end' => '',
+ 'handle_status' => $handleStatus,
+ 'remark' => '链路自检,不对应任何真实订单',
+ 'error_msg' => '',
+ 'create_time' => $now,
+ 'update_time' => $now,
+ ];
+}
+foreach ($orders as $o) {
+ if ((string) $o['waybill_no_code'] === '') {
+ echo "⚠️ 跳过 order_id={$o['order_id']}:还没有运单流水号,说明 7.7 还没成功受理\n";
+ continue;
+ }
+ $batchNo === '' && $batchNo = (string) $o['batch_no'];
+ $list[] = [
+ 'id' => ++$i,
+ 'batch_no' => (string) $o['batch_no'],
+ 'waybill_no' => (string) $o['waybill_no'],
+ 'waybill_no_code' => (string) $o['waybill_no_code'],
+ 'mobile' => (string) $o['mobile'],
+ 'id_card' => (string) $o['id_card'],
+ 'name' => (string) $o['driver_name'],
+ 'quota_amount' => (int) round((float) $o['total_price'] * 100),
+ 'voucher_name' => '运单卡',
+ 'effective_start' => '',
+ 'effective_end' => '',
+ 'handle_status' => $handleStatus,
+ 'remark' => $result === 'success' ? ($msg !== '' ? $msg : '发放成功') : '',
+ 'error_msg' => $result === 'failed' ? ($msg !== '' ? $msg : '模拟发放失败') : '',
+ 'create_time' => $now,
+ 'update_time' => $now,
+ ];
+}
+if (!$list) {
+ exit("没有可推送的明细\n");
+}
+
+$statusText = [3 => '全部成功', 4 => '部分成功', 5 => '处理失败'][$batchStatus] ?? '处理中';
+$data = [
+ 'appId' => (string) ($qyt['APP_ID'] ?? 'SIMAPPID'),
+ 'scene' => 'oil',
+ 'business_license' => '',
+ 'batch_no' => $batchNo,
+ 'passageway' => ((string) ($orders[0]['passageway'] ?? 'alipay')) === 'pingan' ? 2 : 1, // 自检模式下固定 1
+ 'status' => $batchStatus,
+ 'status_text' => $statusText,
+ 'remark' => '模拟推送',
+ 'notify_time' => $now,
+ 'list' => $list,
+];
+$json = json_encode($data, JSON_UNESCAPED_UNICODE);
+
+echo "通知地址:{$url}\n";
+echo "批次:{$batchNo} 批次状态:{$batchStatus}({$statusText}) 明细:" . count($list) . " 条\n";
+if ($fake) {
+ echo "自检模式:运单流水号 {$fakeCode}(刻意不对应任何订单,不会改动任何数据)\n";
+}
+foreach ($orders as $o) {
+ printf(" order_id=%s 油卡号=%s 金额=%s 当前发放态=%s\n",
+ $o['order_id'], $o['oilCardNo'], $o['total_price'], $o['oil_status_zsy']);
+}
+echo "data:" . mb_substr($json, 0, 1500) . (mb_strlen($json) > 1500 ? ' …(截断)' : '') . "\n\n";
+
+if ($dryRun) {
+ echo "--dry-run:未发送\n";
+ exit;
+}
+
+// ---------------- 发送:multipart/form-data,data + sign ----------------
+$ch = curl_init();
+curl_setopt_array($ch, [
+ CURLOPT_URL => $url,
+ CURLOPT_POST => true,
+ // 传数组 => cURL 自动用 multipart/form-data 编码,与企油通口径一致
+ CURLOPT_POSTFIELDS => ['data' => $json, 'sign' => 'SIMULATED'],
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 30,
+ CURLOPT_SSL_VERIFYPEER => false,
+ CURLOPT_SSL_VERIFYHOST => false,
+]);
+$resp = curl_exec($ch);
+$http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
+$err = curl_error($ch);
+curl_close($ch);
+
+echo "HTTP {$http}\n响应:" . ($resp === false ? "cURL 失败:{$err}" : $resp) . "\n";
+// 3.10 的成功判据就是 HTTP 2xx,响应体不做要求
+$ok = $http >= 200 && $http < 300;
+echo $ok ? "接收端已受理(HTTP 2xx)\n" : "⚠️ 接收端未返回 2xx,企油通会重试最多 5 次;请查 runtime/storelog 下当天的 qyt_*.txt\n";
+
+if (!$ok) {
+ exit;
+}
+
+// ---------------- 自检模式:靠日志验证整条链路 ----------------
+if ($fake) {
+ // 日志目录:接收端与队列进程现在统一写 runtime/storelog(见 app/common.php 的
+ // store_log_path)。为了兼容「代码还没全量更新」和历史日志,这里把多应用模式下的
+ // 旧目录 runtime/{api,store}/storelog 也一并扫上。
+ $sub = date('Ym') . '/' . date('Ymd');
+ $dirs = [
+ $root . '/runtime/storelog/' . $sub,
+ $root . '/runtime/api/storelog/' . $sub,
+ $root . '/runtime/store/storelog/' . $sub,
+ ];
+ $grep = static function (array $dirs, string $pattern, string $needle): bool {
+ foreach ($dirs as $dir) {
+ foreach ((array) glob($dir . '/' . $pattern) as $f) {
+ if (strpos((string) @file_get_contents($f), $needle) !== false) {
+ return true;
+ }
+ }
+ }
+ return false;
+ };
+
+ echo "\n等待 callback:queue 处理";
+ $received = false;
+ $consumed = false;
+ for ($n = 0; $n < 10; $n++) {
+ sleep(1);
+ echo '.';
+ $received = $received || $grep($dirs, 'qyt_*.txt', $batchNo);
+ $consumed = $grep($dirs, '[0-9]*.txt', '3.10 发放结果通知处理完成 batch_no=' . $batchNo);
+ if ($consumed) {
+ break;
+ }
+ }
+ echo "\n\n自检结果:\n";
+ printf(" %s 1/3 入口可达,返回 HTTP %d\n", '✅', $http);
+ printf(" %s 2/3 接收端解析到报文(%s 里出现了 batch_no)\n",
+ $received ? '✅' : '❌', $dirs[0] . '/qyt_*.txt');
+ printf(" %s 3/3 callback:queue 消费并完成判定\n", $consumed ? '✅' : '❌');
+
+ // 3/3 过了就说明整条链路都走到了(接收端日志受「详细日志」开关控制,没有不代表没收到)
+ if ($consumed) {
+ echo "\n链路通了。日志里那条「未匹配=1」是预期结果——自检用的运单号本来就不对应任何订单,\n"
+ . "说明「入口 -> 表单解析 -> 入队 -> 消费者取出 -> 逐条判定」每一步都走到了。\n"
+ . "真实通知到达时,这一条会变成「成功=1」或「失败=1」。\n";
+ if (!$received) {
+ echo "\n注:2/3 没打钩不影响功能,常见原因是「详细日志」开关关着\n"
+ . " (后台 ->「企油通对接设置」->「详细日志」)。联调期建议打开,\n"
+ . " 出问题时能看到上游推过来的原文:\n"
+ . " tail -n 50 {$dirs[0]}/qyt_*.txt\n";
+ }
+ exit;
+ }
+ echo "\n排查方向(按顺序排查):\n";
+ if (!$received) {
+ echo " 1) 请求可能根本没打到本站点:确认 --url 指向的就是这台机器,\n"
+ . " 且 nginx 能访问到 public/qytwaybill.php(伪静态别把它拦掉)。\n"
+ . " 先把「详细日志」打开再跑一次,能看到原文就说明收到了。\n";
+ }
+ echo " 2) callback:queue 进程多半没在跑,确认一下:\n"
+ . " /www/server/panel/pyenv/bin/supervisorctl status | grep callback\n"
+ . " 也可以先前台手动跑一个看报错:php think callback:queue\n";
+ echo " 3) Redis 不通的话入队就失败了,接收端日志里会有异常:redis-cli ping\n";
+ exit;
+}
+
+// ---------------- 等队列处理并对比结果 ----------------
+echo "\n等待 callback:queue 处理";
+$ids = array_column($orders, 'order_id');
+$in = implode(',', array_map('intval', $ids));
+$before = [];
+foreach ($orders as $o) {
+ $before[$o['order_id']] = (int) $o['oil_status_zsy'];
+}
+$changed = false;
+for ($n = 0; $n < 10; $n++) {
+ sleep(1);
+ echo '.';
+ $rows = $pdo->query("SELECT order_id, oil_status, oil_status_zsy, oil_text FROM yoshop_partners_order WHERE order_id IN ({$in})")->fetchAll();
+ $changed = true;
+ foreach ($rows as $r) {
+ if ((int) $r['oil_status_zsy'] === $before[$r['order_id']]) {
+ $changed = false;
+ }
+ }
+ if ($changed) {
+ break;
+ }
+}
+echo "\n";
+
+$textOf = [0 => '未提交', 1 => '发放中', 2 => '发放成功', 3 => '发放失败'];
+$rows = $pdo->query("SELECT order_id, oil_status, oil_status_zsy, oil_text FROM yoshop_partners_order WHERE order_id IN ({$in})")->fetchAll();
+foreach ($rows as $r) {
+ printf("%s order_id=%s 发放态 %s -> %s %s\n",
+ (int) $r['oil_status_zsy'] === $before[$r['order_id']] ? '⚠️' : '✅',
+ $r['order_id'],
+ $textOf[$before[$r['order_id']]] ?? $before[$r['order_id']],
+ $textOf[(int) $r['oil_status_zsy']] ?? $r['oil_status_zsy'],
+ $r['oil_text']);
+}
+if (!$changed) {
+ echo "\n可能原因:\n"
+ . " 1) callback:queue 进程没在跑(supervisorctl status 看一下)\n"
+ . " 2) 订单当前不是「发放中」,已经是终态了(状态跃迁只从 1 出发,这是幂等设计)\n"
+ . " 3) 处理异常,查 runtime/storelog 下当天的 qyt_" . date('YmdH') . ".txt\n";
+}
diff --git a/server-tp6/deploy/supervisor-qyt.conf b/server-tp6/deploy/supervisor-qyt.conf
index b0b4149..1be1ea9 100644
--- a/server-tp6/deploy/supervisor-qyt.conf
+++ b/server-tp6/deploy/supervisor-qyt.conf
@@ -18,14 +18,21 @@
;
; 【进程数说明】
; partners:queue 可多进程(同一订单由 RedisLock 串行,安全)
+; qyt:status 可多进程(同上)。7.8 是「一单一次 HTTP 调用」,单进程串行时
+; 在途单一多,一轮扫描就要几十秒,退避间隔会被拖长、分油变慢,
+; 所以默认给 3 个进程。在途量更大时继续加。
; callback:queue / notify:queue 建议保持单进程
+;
+; 【改完必须重启】
+; /www/server/panel/pyenv/bin/supervisorctl update
+; /www/server/panel/pyenv/bin/supervisorctl restart all
; ============================================================
[program:qyt-partners-queue]
command=/www/server/php/74/bin/php think partners:queue
directory=/www/wwwroot/oil
user=www
-numprocs=1
+numprocs=2
process_name=%(program_name)s_%(process_num)02d
autostart=true
autorestart=true
@@ -33,7 +40,7 @@ startsecs=3
startretries=99
stopwaitsecs=10
redirect_stderr=true
-stdout_logfile=/www/wwwlogs/supervisor/qyt-partners-queue.log
+stdout_logfile=/www/wwwlogs/supervisor/qyt-partners-queue-%(process_num)02d.log
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=5
@@ -41,7 +48,7 @@ stdout_logfile_backups=5
command=/www/server/php/74/bin/php think qyt:status
directory=/www/wwwroot/oil
user=www
-numprocs=1
+numprocs=3
process_name=%(program_name)s_%(process_num)02d
autostart=true
autorestart=true
@@ -49,7 +56,7 @@ startsecs=3
startretries=99
stopwaitsecs=10
redirect_stderr=true
-stdout_logfile=/www/wwwlogs/supervisor/qyt-status-queue.log
+stdout_logfile=/www/wwwlogs/supervisor/qyt-status-queue-%(process_num)02d.log
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=5
diff --git a/server-tp6/public/index.html b/server-tp6/public/index.html
index 9e2cd24..6a84d57 100644
--- a/server-tp6/public/index.html
+++ b/server-tp6/public/index.html
@@ -1,2 +1,2 @@
加油
\ No newline at end of file
+ document.write('') |