diff --git a/addons/address/.addonrc b/addons/address/.addonrc new file mode 100644 index 0000000..6ca1422 --- /dev/null +++ b/addons/address/.addonrc @@ -0,0 +1 @@ +{"files":["public\\assets\\addons\\address\\js\\gcoord.min.js","public\\assets\\addons\\address\\js\\jquery.autocomplete.js"],"license":"regular","licenseto":"26425","licensekey":"yO6C1uwFmRnsXIkp klcX6YFU+U3ad3Xy5gdiig==","domains":["fenleixinxi"],"licensecodes":[],"validations":["bd9cd2ccafa213b7a49c36d1a5919d55"]} \ No newline at end of file diff --git a/addons/address/Address.php b/addons/address/Address.php new file mode 100644 index 0000000..4bc36ea --- /dev/null +++ b/addons/address/Address.php @@ -0,0 +1,32 @@ + + */ +class Address extends Addons +{ + + /** + * 插件安装方法 + * @return bool + */ + public function install() + { + return true; + } + + /** + * 插件卸载方法 + * @return bool + */ + public function uninstall() + { + return true; + } + +} diff --git a/addons/address/bootstrap.js b/addons/address/bootstrap.js new file mode 100644 index 0000000..655162d --- /dev/null +++ b/addons/address/bootstrap.js @@ -0,0 +1,34 @@ +require([], function () { + //绑定data-toggle=addresspicker属性点击事件 + + $(document).on('click', "[data-toggle='addresspicker']", function () { + var that = this; + var callback = $(that).data('callback'); + var input_id = $(that).data("input-id") ? $(that).data("input-id") : ""; + var lat_id = $(that).data("lat-id") ? $(that).data("lat-id") : ""; + var lng_id = $(that).data("lng-id") ? $(that).data("lng-id") : ""; + var zoom_id = $(that).data("zoom-id") ? $(that).data("zoom-id") : ""; + var lat = lat_id ? $("#" + lat_id).val() : ''; + var lng = lng_id ? $("#" + lng_id).val() : ''; + var zoom = zoom_id ? $("#" + zoom_id).val() : ''; + var url = "/addons/address/index/select"; + url += (lat && lng) ? '?lat=' + lat + '&lng=' + lng + (input_id ? "&address=" + $("#" + input_id).val() : "") + (zoom ? "&zoom=" + zoom : "") : ''; + Fast.api.open(url, '位置选择', { + callback: function (res) { + input_id && $("#" + input_id).val(res.address).trigger("change"); + lat_id && $("#" + lat_id).val(res.lat).trigger("change"); + lng_id && $("#" + lng_id).val(res.lng).trigger("change"); + zoom_id && $("#" + zoom_id).val(res.zoom).trigger("change"); + + try { + //执行回调函数 + if (typeof callback === 'function') { + callback.call(that, res); + } + } catch (e) { + + } + } + }); + }); +}); diff --git a/addons/address/config.php b/addons/address/config.php new file mode 100644 index 0000000..208286b --- /dev/null +++ b/addons/address/config.php @@ -0,0 +1,132 @@ + 'maptype', + 'title' => '默认地图类型', + 'type' => 'radio', + 'content' => [ + 'baidu' => '百度地图', + 'amap' => '高德地图', + 'tencent' => '腾讯地图', + ], + 'value' => 'tencent', + 'rule' => 'required', + 'msg' => '', + 'tip' => '', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'zoom', + 'title' => '默认缩放级别', + 'type' => 'string', + 'content' => [], + 'value' => '11', + 'rule' => 'required', + 'msg' => '', + 'tip' => '', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'lat', + 'title' => '默认Lat', + 'type' => 'string', + 'content' => [], + 'value' => '26.647000', + 'rule' => 'required', + 'msg' => '', + 'tip' => '', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'lng', + 'title' => '默认Lng', + 'type' => 'string', + 'content' => [], + 'value' => '106.630000', + 'rule' => 'required', + 'msg' => '', + 'tip' => '', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'baidukey', + 'title' => '百度地图KEY', + 'type' => 'string', + 'content' => [], + 'value' => '', + 'rule' => '', + 'msg' => '', + 'tip' => '', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'amapkey', + 'title' => '高德地图KEY', + 'type' => 'string', + 'content' => [], + 'value' => '', + 'rule' => '', + 'msg' => '', + 'tip' => '', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'amapsecurityjscode', + 'title' => '高德地图安全密钥', + 'type' => 'string', + 'content' => [], + 'value' => '', + 'rule' => '', + 'msg' => '', + 'tip' => '', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'tencentkey', + 'title' => '腾讯地图KEY', + 'type' => 'string', + 'content' => [], + 'value' => 'WM7BZ-ZAYLL-MZEPZ-MRZBT-MBYJE-ZAFIT', + 'rule' => '', + 'msg' => '', + 'tip' => '', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'coordtype', + 'title' => '坐标系类型', + 'type' => 'select', + 'content' => [ + 'DEFAULT' => '默认(使用所选地图默认坐标系)', + 'GCJ02' => 'GCJ-02', + 'BD09' => 'BD-09', + ], + 'value' => 'DEFAULT', + 'rule' => 'required', + 'msg' => '', + 'tip' => '', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => '__tips__', + 'title' => '温馨提示', + 'type' => '', + 'content' => [], + 'value' => '请先申请对应地图的Key,配置后再使用', + 'rule' => '', + 'msg' => '', + 'tip' => '', + 'ok' => '', + 'extend' => 'alert-danger-light', + ], +]; diff --git a/addons/address/controller/Index.php b/addons/address/controller/Index.php new file mode 100644 index 0000000..5cd31e3 --- /dev/null +++ b/addons/address/controller/Index.php @@ -0,0 +1,64 @@ +request->langset(); + $lang = preg_match("/^([a-zA-Z\-_]{2,10})\$/i", $lang) ? $lang : 'zh-cn'; + + $site = Config::get("site"); + + // 配置信息 + $config = [ + 'site' => array_intersect_key($site, array_flip(['name', 'cdnurl', 'version', 'timezone', 'languages'])), + 'upload' => null, + 'modulename' => 'addons', + 'controllername' => 'index', + 'actionname' => 'index', + 'jsname' => 'addons/address', + 'moduleurl' => '', + 'language' => $lang + ]; + $config = array_merge($config, Config::get("view_replace_str")); + + // 配置信息后 + Hook::listen("config_init", $config); + // 加载当前控制器语言包 + $this->view->assign('site', $site); + $this->view->assign('config', $config); + + return $this->view->fetch(); + } + + // 选择地址 + public function select() + { + $config = get_addon_config('address'); + $zoom = (int)$this->request->get('zoom', $config['zoom']); + $lng = (float)$this->request->get('lng'); + $lat = (float)$this->request->get('lat'); + $address = $this->request->get('address'); + $lng = $lng ?: $config['lng']; + $lat = $lat ?: $config['lat']; + $this->view->assign('zoom', $zoom); + $this->view->assign('lng', $lng); + $this->view->assign('lat', $lat); + $this->view->assign('address', $address); + $maptype = $config['maptype']; + if (!isset($config[$maptype . 'key']) || !$config[$maptype . 'key']) { + $this->error("请在配置中配置对应类型地图的密钥"); + } + return $this->view->fetch('index/' . $maptype); + } + +} diff --git a/addons/address/info.ini b/addons/address/info.ini new file mode 100644 index 0000000..c0f914b --- /dev/null +++ b/addons/address/info.ini @@ -0,0 +1,10 @@ +name = address +title = 地址位置选择插件 +intro = 地图位置选择插件,可返回地址和经纬度 +author = FastAdmin +website = +version = 1.1.8 +state = 1 +url = /addons/address +license = regular +licenseto = 26425 diff --git a/addons/address/view/index/amap.html b/addons/address/view/index/amap.html new file mode 100644 index 0000000..62972fc --- /dev/null +++ b/addons/address/view/index/amap.html @@ -0,0 +1,258 @@ + + + + + 地址选择器 + + + + + + +
确定
+
+ + + + + + + + + diff --git a/addons/address/view/index/baidu.html b/addons/address/view/index/baidu.html new file mode 100644 index 0000000..86aa74b --- /dev/null +++ b/addons/address/view/index/baidu.html @@ -0,0 +1,243 @@ + + + + + 地址选择器 + + + + + + +
确定
+
+ + + + + + diff --git a/addons/address/view/index/index.html b/addons/address/view/index/index.html new file mode 100644 index 0000000..07f9712 --- /dev/null +++ b/addons/address/view/index/index.html @@ -0,0 +1,132 @@ + + + + + 地图位置(经纬度)选择插件 + + + + +
+ +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ + +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
参数释义
data-input-id填充地址的文本框ID
data-lng-id填充经度的文本框ID
data-lat-id填充纬度的文本框ID
data-zoom-id填充缩放的文本框ID
+
+ +
+
+
+ +
+ + + + + + + + + diff --git a/addons/address/view/index/tencent.html b/addons/address/view/index/tencent.html new file mode 100644 index 0000000..89df386 --- /dev/null +++ b/addons/address/view/index/tencent.html @@ -0,0 +1,291 @@ + + + + + 地址选择器 + + + + + + +
确定
+
+ + + + + + + + + diff --git a/addons/epay/certs/apiclient_cert.pem b/addons/epay/certs/apiclient_cert.pem index e69de29..ad68c62 100644 --- a/addons/epay/certs/apiclient_cert.pem +++ b/addons/epay/certs/apiclient_cert.pem @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIELjCCAxagAwIBAgIUZDUZYP2jboFh2yUKMMx6fYXLEGIwDQYJKoZIhvcNAQEL +BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT +FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg +Q0EwHhcNMjYxMDE3MDIxNjIxWhcNMzExMDE2MDIxNjIxWjCBhzETMBEGA1UEAwwK +MTExODg5ODAxODEbMBkGA1UECgwS5b6u5L+h5ZWG5oi357O757ufMTMwMQYDVQQL +DCrotLXlt57nlLDpl7TlsI/ku5Pnp5HmioDmnInpmZDotKPku7vlhazlj7gxCzAJ +BgNVBAYTAkNOMREwDwYDVQQHDAhTaGVuWmhlbjCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBALubtbejpAiZJiXXQOU0k+1OoJMXXXoiab98FVSASvI6oudj +CwiSX9kPL5yNB5W96MCsoak19bDps6cZ9xk/Zd0oF7cMMc2qNGshrxwYYCnpuwF4 +/uyUjKHkIUqUgCq6jxitqWcPH/pCiIklNJ+I0J2jsu3m/Nb/Q1i2FPOhYG2MeOdE +hHuNwe3xAWdTQJxoVTGx5XgkjuNfyzR+Gqa5cXh/v5PVCBfpFPtwWgWRy7Epdenu +nQ+SKCakicTgfK80oFXUVXNfYsHK3S1ZdX8ad5nGQS5rDZ4XKUW8T3smrfxkQ7h/ +8Y41ttQxeAiO2LZ5n9SNQxF5dXh5Ez27xg1ZGNMCAwEAAaOBuTCBtjAJBgNVHRME +AjAAMAsGA1UdDwQEAwID+DCBmwYDVR0fBIGTMIGQMIGNoIGKoIGHhoGEaHR0cDov +L2V2Y2EuaXRydXMuY29tLmNuL3B1YmxpYy9pdHJ1c2NybD9DQT0xQkQ0MjIwRTUw +REJDMDRCMDZBRDM5NzU0OTg0NkMwMUMzRThFQkQyJnNnPUhBQ0M0NzFCNjU0MjJF +MTJCMjdBOUQzM0E4N0FEMUNERjU5MjZFMTQwMzcxMA0GCSqGSIb3DQEBCwUAA4IB +AQAyU4UMwSU3Zf7k3xV85FkN6kekr3JXzg+UeOYSTUcylQrgMsW6W6s5jafuoJAf +MYWPB8Sc9fDoKYHOBK237hU2+BeAf6iBvrVdTbhW8v9aGgCUaf/Uf0T4tEGMmOu6 +PZq+h04WgtLW6jyps3rcn/Cgc3Femz9UjP28k4wlIOwo1o5UJKlZRlfZ9G0i8bCZ +CA2SSCL3UL9N1vMzb2HXhVkwwSJr0dk9rE67ijuh64UuERpUkPArmGUFYqcXV17U +rs3Yrd6VR2XITE0HkpxCxtR3QIs4385VdgwQ5RsQQQbIIqoXf0Pz1qyz5OCzOPjp +6qN93xkOIzIkLy0jPwAx0DZV +-----END CERTIFICATE----- diff --git a/addons/epay/certs/apiclient_key.pem b/addons/epay/certs/apiclient_key.pem index e69de29..26a6ffc 100644 --- a/addons/epay/certs/apiclient_key.pem +++ b/addons/epay/certs/apiclient_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7m7W3o6QImSYl +10DlNJPtTqCTF116Imm/fBVUgEryOqLnYwsIkl/ZDy+cjQeVvejArKGpNfWw6bOn +GfcZP2XdKBe3DDHNqjRrIa8cGGAp6bsBeP7slIyh5CFKlIAquo8YralnDx/6QoiJ +JTSfiNCdo7Lt5vzW/0NYthTzoWBtjHjnRIR7jcHt8QFnU0CcaFUxseV4JI7jX8s0 +fhqmuXF4f7+T1QgX6RT7cFoFkcuxKXXp7p0PkigmpInE4HyvNKBV1FVzX2LByt0t +WXV/GneZxkEuaw2eFylFvE97Jq38ZEO4f/GONbbUMXgIjti2eZ/UjUMReXV4eRM9 +u8YNWRjTAgMBAAECggEAcWUAROsH7pqC0ER22RMahkkMDVwGCBhcynx5eAXDRv3i +EPFwbdHrVQs0v+8OMpgC4tJ1NA2FX1UiXVH4ZK5JJ49gJiecwq6p2EcXzGd/WFYJ +zi4QB3cSOZfl92oivbDdZREyXzRpI56rYBm44OSVE3MqZ1lCYcEQN8J2j3tvCUbh +NAhm6wgaTdx7PmwXyualb2aWX6X0jxwcNm8MPHpSSH73pjUMN0zZetMGfVr42utM +ltSU1yLEYkW4VvopWtW+jSD1ry4gVnSqO3TaS8mwiOv9LSPSEXdN6mSHtrWLmDCb +vIOUUzf0HhVRJw3HQb34ZTCwLaGX4GyaL2LA2cdjoQKBgQDo0vCWxQL6QKsJOiKA +4EtAqW+jtvprCZyR37R+xHUwwZAW30czMOWKNp4speVg7Vhabr9SDBM8FAyYvctx +XjoyawbYjRHR7P1d9ruKFv+TK4RthG8O/cmvq4/BhvUwJfnVP42qkjAI2a9mk7XE +xyhrvihFPdUvopUPmKgf13MOZQKBgQDOSIk2uCAV1jqP5tQ2/afEbvNmjTPVLg69 +BjtcXyKTx9w+gLgBIXs526t4XUKLP0DPxsPIKLKTN4VJRxKPdXw+LZqsSHxBXL7P +Kz+azMSZtIQe1KhSJEMJCX9XQjPoGUpTS8ogKhYf/jioMJVBpF4xTQ/Ujc6vI3CZ +gHCvQT7a1wKBgD+KgjrDneJqj8M+6He/vwnwtBU8NO/NebueEAag21LpIMf9O9NO +vvaPnW+V/xoK7JgRrYlY3k8BMcbl1YYlL0DfUMj9ByYBxcxFMD2ptSqXL9cr05uF +y+tzqRIqIPR5CMwYQjORHibsMiCOo4cGy5NMHHV5QJt1hbTx9sBp7m65AoGAFe3i +IF4MkyW2Dgw07S+kAR7mYzWJmBUQXgnmj5hRfbJWeJKIfR5JEkqG/QG1DEaKJkx0 +Dbg926agVbznfTa5IYcJlV0TUIOCiVWIqnUwRULKdNiYmDrgoXpP/j+8cuUVYKuC +7k5rEbWd4ktf4p2NoeIWV7tHiJjU5Iyh7g+alfsCgYEAtYcLzzZ8Wmp5zeI9e0g6 +kXTK+4oBPs+L4f9rs5YcwKqtTRS0rFI7noncTnlvabmR1BQM1vb26veWAbnoydv6 +g35qCUttXauL4EERY/VeNelbCuEwhKuxLv0s3FlGMTN70PLqKAm3JNdKMgzvnUA6 +S3gxh/fyFLprRfkLbeQz6dY= +-----END PRIVATE KEY----- diff --git a/addons/epay/certs/public_key.pem b/addons/epay/certs/public_key.pem index e69de29..a1c834c 100644 --- a/addons/epay/certs/public_key.pem +++ b/addons/epay/certs/public_key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7QW1+UaVd76VUmnEWZ8Z +pkUFQcK3QElxwKhb8fuY6i+DZR/i6MNI9GEW2N/4qWa/WRHHx9jwOas8ORyp1f8/ +CgY1aGhC9h/m9B7IJHGb9zJXxQnkJOLaOYk8g5p2wGG1THVLNryy/8KKRwCdZJ/P +gy5MB3hteK/5rp1/HMnVMdHrCHgt9L+diyNGb7FW6d2RT5vM2+jRrdbpiFwIuQYT ++uGMQyE9qKdnAlTvqr4J/T0dtdBd6vidEktEDb+UnPsufa5LWk2MbF66diw6TE8w +lcsX7EvSJEd1tVBHn6MOeHoEs1iqfKI557fCTlLitGk4SszlKusR0OmbrEVhBigV +uQIDAQAB +-----END PUBLIC KEY----- diff --git a/addons/epay/config.php b/addons/epay/config.php index c650f28..720e1e2 100644 --- a/addons/epay/config.php +++ b/addons/epay/config.php @@ -6,7 +6,7 @@ return [ 'title' => 'API版本(请勿修改该值)', 'type' => 'radio', 'content' => [], - 'value' => 'v2', + 'value' => 'v3', 'rule' => '', 'msg' => '', 'tip' => 'V2版本只支持微信支付V2密钥,V3版本只支持微信支付V3密钥,请勿修改该值!!!', @@ -25,8 +25,8 @@ return [ 'miniapp_id' => 'wxbfcb778f3019cff8', 'mch_id' => '1118898018', 'key' => 'cfgherdhjbfshf3356586feejfbvjvhe', - 'key_v3' => '', - 'public_key_id' => '', + 'key_v3' => 'cfgherdhjbfshf3356586feejfbvjvhe', + 'public_key_id' => 'PUB_KEY_ID_0111188980182026101700181872001201', 'public_key' => '/addons/epay/certs/public_key.pem', 'cert_client' => '/addons/epay/certs/apiclient_cert.pem', 'cert_key' => '/addons/epay/certs/apiclient_key.pem', diff --git a/addons/epay/library/v3new/Yansongda/Pay/Shortcut/Wechat/MchTransferShortcut.php b/addons/epay/library/v3new/Yansongda/Pay/Shortcut/Wechat/MchTransferShortcut.php new file mode 100644 index 0000000..aa80f17 --- /dev/null +++ b/addons/epay/library/v3new/Yansongda/Pay/Shortcut/Wechat/MchTransferShortcut.php @@ -0,0 +1,93 @@ +mchTransfer([...]); // 发起转账 + * Pay::wechat($config)->mchTransfer(['_action' => 'query', 'out_bill_no' => ...]); // 查询 + * + * 说明:该文件属于 epay 插件内置 SDK 的补丁,插件升级后需重新确认是否存在。 + */ +class MchTransferShortcut implements ShortcutInterface +{ + /** + * @throws InvalidParamsException + */ + public function getPlugins(array $params): array + { + $method = Str::camel($params['_action'] ?? 'default').'Plugins'; + + if (method_exists($this, $method)) { + return $this->{$method}(); + } + + throw new InvalidParamsException( + Exception::PARAMS_SHORTCUT_ACTION_INVALID, + "不支持的 action 参数 [{$method}],可用:默认(发起转账)、query(查询)" + ); + } + + protected function defaultPlugins(): array + { + return $this->createPlugins(); + } + + protected function createPlugins(): array + { + return [ + StartPlugin::class, + CreatePlugin::class, + AddPayloadBodyPlugin::class, + AddPayloadSignaturePlugin::class, + AddRadarPlugin::class, + VerifySignaturePlugin::class, + ResponsePlugin::class, + ParserPlugin::class, + ]; + } + + protected function queryPlugins(): array + { + return [ + StartPlugin::class, + QueryPlugin::class, + AddPayloadSignaturePlugin::class, + AddRadarPlugin::class, + VerifySignaturePlugin::class, + ResponsePlugin::class, + ParserPlugin::class, + ]; + } + + protected function cancelPlugins(): array + { + return [ + StartPlugin::class, + \Yansongda\Pay\Plugin\Wechat\V3\Marketing\MchTransfer\CancelPlugin::class, + AddPayloadSignaturePlugin::class, + AddRadarPlugin::class, + VerifySignaturePlugin::class, + ResponsePlugin::class, + ParserPlugin::class, + ]; + } +} diff --git a/application/admin/command/FcInstall.php b/application/admin/command/FcInstall.php index 0199cf4..1f1e354 100644 --- a/application/admin/command/FcInstall.php +++ b/application/admin/command/FcInstall.php @@ -83,6 +83,15 @@ class FcInstall extends Command $output->writeln('==> LBS / 店铺装修 / 支付 / 优惠券 / 举报升级(v1.7.0)'); $this->upgradeV170($output); + $output->writeln('==> 提现商家转账字段升级(v1.2.1)'); + $this->upgradeV121($output); + + $output->writeln('==> 优惠券抵扣 / 佣金结算字段升级(v1.2.2)'); + $this->upgradeV122($output); + + $output->writeln('==> 服务区域平铺升级(省 / 市 / 区县)'); + $this->upgradeArea($output); + $output->writeln('==> 初始化后台菜单'); $this->menu($output); @@ -329,6 +338,226 @@ class FcInstall extends Command : ' - LBS / 店铺装修 / 支付 / 优惠券 / 举报配置已是最新,无需处理'); } + /** + * v1.2.1:提现「商家转账」字段 + * - fa_fc_withdraw:out_bill_no / transfer_status / transfer_id / package_info / fail_reason / transfer_time + * - fa_fc_user_ext:realname(收款人真实姓名) + * 幂等:已存在则跳过 + */ + private function upgradeV121(Output $output) + { + $prefix = \think\Config::get('database.prefix'); + $w = $prefix . 'fc_withdraw'; + $e = $prefix . 'fc_user_ext'; + + $cols = [ + 'out_bill_no' => "ALTER TABLE `{$w}` ADD COLUMN `out_bill_no` varchar(32) NOT NULL DEFAULT '' COMMENT '微信商家转账商户单号'", + 'transfer_status' => "ALTER TABLE `{$w}` ADD COLUMN `transfer_status` varchar(16) NOT NULL DEFAULT '' COMMENT 'WAIT_USER_CONFIRM/PROCESSING/SUCCESS/FAIL/CANCELLED'", + 'transfer_id' => "ALTER TABLE `{$w}` ADD COLUMN `transfer_id` varchar(64) NOT NULL DEFAULT '' COMMENT '微信转账单号'", + 'package_info' => "ALTER TABLE `{$w}` ADD COLUMN `package_info` varchar(512) NOT NULL DEFAULT '' COMMENT '拉起确认收款页的package'", + 'fail_reason' => "ALTER TABLE `{$w}` ADD COLUMN `fail_reason` varchar(255) NOT NULL DEFAULT ''", + 'transfer_time' => "ALTER TABLE `{$w}` ADD COLUMN `transfer_time` int(10) DEFAULT NULL", + ]; + + $changed = 0; + foreach ($cols as $col => $sql) { + if ($this->hasColumn($w, $col)) { + continue; + } + Db::execute($sql); + $changed++; + } + + try { + Db::execute("ALTER TABLE `{$w}` ADD KEY `out_bill_no` (`out_bill_no`)"); + $changed++; + } catch (\Exception $e2) { + // 索引已存在则忽略 + } + + if (!$this->hasColumn($e, 'realname')) { + Db::execute("ALTER TABLE `{$e}` ADD COLUMN `realname` varchar(32) NOT NULL DEFAULT '' COMMENT '收款人真实姓名(提现用)'"); + $changed++; + } + + // 退款单号(微信退款结果回调按此匹配订单) + $o = $prefix . 'fc_order'; + if (!$this->hasColumn($o, 'out_refund_no')) { + Db::execute("ALTER TABLE `{$o}` ADD COLUMN `out_refund_no` varchar(64) NOT NULL DEFAULT '' COMMENT '微信退款单号(商户侧)'"); + $changed++; + } + + $output->writeln($changed + ? ' - 完成(新增结构 ' . $changed . ' 处)' + : ' - 提现转账字段已是最新,无需处理'); + } + + /** + * 服务区域平铺升级(省 / 市 / 区县三列,不再使用层级) + * + * 目标结构:fc_area(id, province, city, district, lat, lng, sort, enabled) + * 每条记录 = 一个服务区域(区县);省 / 市只是该行的归属字段,供前端级联。 + * 历史层级数据会自动压平:每个区县保留原 ID,省 / 市节点删除。 + * 幂等:可重复执行。 + */ + private function upgradeArea(Output $output) + { + $prefix = \think\Config::get('database.prefix'); + $areaTable = $prefix . 'fc_area'; + $postTable = $prefix . 'fc_post'; + $draftTable = $prefix . 'fc_draft'; + $now = time(); + $changed = 0; + + // ① fc_area 平铺字段 + $areaCols = [ + 'province' => "ALTER TABLE `{$areaTable}` ADD COLUMN `province` varchar(32) NOT NULL DEFAULT '' COMMENT '省份' AFTER `id`", + 'city' => "ALTER TABLE `{$areaTable}` ADD COLUMN `city` varchar(32) NOT NULL DEFAULT '' COMMENT '城市' AFTER `province`", + 'district' => "ALTER TABLE `{$areaTable}` ADD COLUMN `district` varchar(32) NOT NULL DEFAULT '' COMMENT '区县(最终服务区域)' AFTER `city`", + 'lat' => "ALTER TABLE `{$areaTable}` ADD COLUMN `lat` decimal(10,6) DEFAULT NULL COMMENT '中心纬度(gcj02)' AFTER `district`", + 'lng' => "ALTER TABLE `{$areaTable}` ADD COLUMN `lng` decimal(10,6) DEFAULT NULL COMMENT '中心经度(gcj02)' AFTER `lat`", + ]; + foreach ($areaCols as $col => $sql) { + if ($this->hasColumn($areaTable, $col)) { + continue; + } + Db::execute($sql); + $changed++; + } + if (!$this->hasIndex($areaTable, 'province_city')) { + Db::execute("ALTER TABLE `{$areaTable}` ADD KEY `province_city` (`province`,`city`)"); + } + + // ② 旧层级数据压平:区县保留原 ID,省 / 市节点删除 + $hasLevel = $this->hasColumn($areaTable, 'level'); + $hasParent = $this->hasColumn($areaTable, 'parent_id'); + $hasName = $this->hasColumn($areaTable, 'name'); + + if ($hasLevel || $hasParent || $hasName) { + $rows = Db::name('fc_area')->select(); + $byId = []; + foreach ($rows as $row) { + $byId[(int)$row['id']] = $row; + } + + $flat = 0; + foreach ($rows as $row) { + $level = $hasLevel ? (int)$row['level'] : 0; + // 只保留区县(level=3);真实省份/城市节点稍后删除 + if ($hasLevel && $level > 0 && $level < 3) { + continue; + } + + $provinceName = ''; + $cityName = ''; + $districtName = $hasName ? (string)$row['name'] : (string)$row['district']; + + if ($hasParent && (int)$row['parent_id'] > 0 && isset($byId[(int)$row['parent_id']])) { + $cityRow = $byId[(int)$row['parent_id']]; + $cityName = $hasName ? (string)$cityRow['name'] : (string)$cityRow['city']; + if ((int)$cityRow['parent_id'] > 0 && isset($byId[(int)$cityRow['parent_id']])) { + $provinceRow = $byId[(int)$cityRow['parent_id']]; + $provinceName = $hasName ? (string)$provinceRow['name'] : (string)$provinceRow['province']; + } + } + + Db::name('fc_area')->where('id', $row['id'])->update([ + 'province' => $provinceName, + 'city' => $cityName, + 'district' => $districtName, + 'updatetime' => $now, + ]); + $flat++; + } + + // 删除省 / 市节点(它们不是服务区域) + if ($hasLevel) { + $deleted = Db::name('fc_area')->where('level', 'in', [1, 2])->delete(); + } + // 删除旧字段,彻底去掉层级概念 + foreach (['parent_id', 'level', 'name'] as $col) { + if ($this->hasColumn($areaTable, $col)) { + Db::execute("ALTER TABLE `{$areaTable}` DROP COLUMN `{$col}`"); + } + } + $output->writeln(' - 已将 ' . $flat . ' 条区域压平为「省 / 市 / 区县」三列'); + } + + // ③ 已知区县中心坐标(仅补空值) + $coords = [ + '云岩区' => [26.605, 106.710], + '南明区' => [26.568, 106.716], + '观山湖区' => [26.625, 106.625], + '花溪区' => [26.430, 106.670], + '白云区' => [26.678, 106.623], + ]; + foreach ($coords as $name => $ll) { + Db::name('fc_area')->where('district', $name)->whereNull('lat')->update([ + 'lat' => $ll[0], 'lng' => $ll[1], 'updatetime' => $now, + ]); + } + + // ④ fc_post:保留 district_id 作为服务区域ID,移除省/市 ID 列 + if (!$this->hasColumn($postTable, 'district_id')) { + Db::execute("ALTER TABLE `{$postTable}` ADD COLUMN `district_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '服务区域ID(fc_area.id)' AFTER `area`"); + $changed++; + } + foreach (['province_id', 'city_id'] as $col) { + if ($this->hasColumn($postTable, $col)) { + Db::execute("ALTER TABLE `{$postTable}` DROP COLUMN `{$col}`"); + } + } + if (!$this->hasIndex($postTable, 'district_id')) { + Db::execute("ALTER TABLE `{$postTable}` ADD KEY `district_id` (`district_id`)"); + } + + // ⑤ fc_draft:同样只保留 area + district_id + foreach (['area', 'district_id'] as $col) { + if ($this->hasColumn($draftTable, $col)) { + continue; + } + $sql = $col === 'area' + ? "ALTER TABLE `{$draftTable}` ADD COLUMN `area` varchar(32) NOT NULL DEFAULT '' COMMENT '区县名称' AFTER `address`" + : "ALTER TABLE `{$draftTable}` ADD COLUMN `district_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '服务区域ID(fc_area.id)' AFTER `area`"; + Db::execute($sql); + $changed++; + } + foreach (['province_id', 'city_id'] as $col) { + if ($this->hasColumn($draftTable, $col)) { + Db::execute("ALTER TABLE `{$draftTable}` DROP COLUMN `{$col}`"); + } + } + + // ⑥ 按区县名称回填 district_id(信息 / 草稿) + $byName = []; + foreach (Db::name('fc_area')->select() as $area) { + $byName[(string)$area['district']] = (int)$area['id']; + } + $backfilled = 0; + foreach ([['fc_post', 'updatetime'], ['fc_draft', null]] as $t) { + $rows = Db::name($t[0])->where('district_id', 0)->where('area', '<>', '')->field('id,area')->select(); + foreach ($rows as $row) { + if (!isset($byName[$row['area']])) { + continue; + } + $data = ['district_id' => $byName[$row['area']]]; + if ($t[1]) { + $data[$t[1]] = $now; + } + Db::name($t[0])->where('id', $row['id'])->update($data); + $backfilled++; + } + } + if ($backfilled) { + $output->writeln(' - 已回填 ' . $backfilled . ' 条信息/草稿的服务区域ID'); + } + + $output->writeln($changed || $backfilled + ? ' - 完成(结构变更 ' . $changed . ' 处,回填 ' . $backfilled . ' 条)' + : ' - 服务区域已是平铺结构,无需处理'); + } + + /** 表是否存在某列 */ private function hasColumn($table, $column) { @@ -336,6 +565,48 @@ class FcInstall extends Command return !empty($cols); } + /** + * v1.2.2:优惠券抵扣 + 佣金结算周期 + * - fa_fc_order:origin_amount / coupon_amount / coupon_id + * - fa_fc_commission:settle_due(预计结算时间) + */ + private function upgradeV122(Output $output) + { + $prefix = \think\Config::get('database.prefix'); + $o = $prefix . 'fc_order'; + $c = $prefix . 'fc_commission'; + + $changed = 0; + + $orderCols = [ + 'origin_amount' => "ALTER TABLE `{$o}` ADD COLUMN `origin_amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '优惠前金额'", + 'coupon_amount' => "ALTER TABLE `{$o}` ADD COLUMN `coupon_amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '优惠券抵扣金额'", + 'coupon_id' => "ALTER TABLE `{$o}` ADD COLUMN `coupon_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '使用的用户优惠券ID'", + ]; + foreach ($orderCols as $col => $sql) { + if (!$this->hasColumn($o, $col)) { + Db::execute($sql); + $changed++; + } + } + + if (!$this->hasColumn($c, 'settle_due')) { + Db::execute("ALTER TABLE `{$c}` ADD COLUMN `settle_due` int(10) DEFAULT NULL COMMENT '预计结算时间'"); + $changed++; + } + + $output->writeln($changed + ? ' - 完成(新增结构 ' . $changed . ' 处)' + : ' - 优惠券/结算字段已是最新,无需处理'); + } + + /** 表是否存在某个索引 */ + private function hasIndex($table, $index) + { + $rows = Db::query("SHOW INDEX FROM `{$table}` WHERE Key_name = '" . addslashes($index) . "'"); + return !empty($rows); + } + /** 读配置默认值(不依赖 api 基类的 cfg 缓存) */ private function cfgValue($key, $default = null) { @@ -350,184 +621,15 @@ class FcInstall extends Command /** * 初始化 FastAdmin 后台菜单(幂等) * - * 结构: - * 同城业务 (fc) - * ├─ 内容管理 分类管理 / 轮播管理 / 信息管理 / 评价管理 / 举报处理 - * ├─ 用户与商家 商家管理 / 会员管理 / 消息管理 - * ├─ 交易管理 订单管理 / 会员套餐 / 优惠券管理 / 资金流水 / 佣金明细 / 付费查看记录 - * ├─ 分销管理 分销关系 / 提现审核 - * └─ 基础数据 服务区域 / 草稿箱 - * v1.7.0 追加(幂等):数据总览 fc/dashboard(置首)、系统设置 fc/setting(置末), - * 以及上述「交易管理」下的优惠券管理 fc/coupon 与它们的节点权限。 + * 菜单结构统一定义在 FcMenu::GROUPS / FcMenu::ITEMS, + * 保证 fc:install 与 fc:menu 产出同一套菜单。 */ protected function menu(Output $output) { - $now = time(); - $ruleTable = Db::name('auth_rule'); - - // 1) 顶级菜单 - $top = $ruleTable->where('name', 'fc')->find(); - $topData = [ - 'type' => 'file', 'title' => '同城业务', 'icon' => 'fa fa-map-marker', - 'ismenu' => 1, 'menutype' => 'addtabs', 'weigh' => 95, - 'status' => 'normal', 'updatetime' => $now, - ]; - if ($top) { - $ruleTable->where('id', $top['id'])->update($topData); - $topId = (int)$top['id']; - } else { - $topData['pid'] = 0; - $topData['name'] = 'fc'; - $topData['url'] = ''; - $topData['createtime'] = $now; - $topId = (int)$ruleTable->insertGetId($topData); - } - - // 2) 分组菜单 - $groups = [ - ['fc/content', '内容管理', 'fa fa-file-text-o', 90], - ['fc/user', '用户与商家', 'fa fa-users', 80], - ['fc/trade', '交易管理', 'fa fa-shopping-cart', 70], - ['fc/dist', '分销管理', 'fa fa-share-alt', 60], - ['fc/base', '基础数据', 'fa fa-database', 50], - ]; - $groupId = []; - foreach ($groups as $g) { - $row = $ruleTable->where('name', $g[0])->find(); - $data = [ - 'type' => 'menu', 'pid' => $topId, 'title' => $g[1], 'icon' => $g[2], - 'url' => '', 'ismenu' => 1, 'menutype' => 'addtabs', - 'weigh' => $g[3], 'status' => 'normal', 'updatetime' => $now, - ]; - if ($row) { - $ruleTable->where('id', $row['id'])->update($data); - $groupId[$g[0]] = (int)$row['id']; - } else { - $data['name'] = $g[0]; - $data['createtime'] = $now; - $groupId[$g[0]] = (int)$ruleTable->insertGetId($data); - } - } - - // 3) 各业务控制器归组 + 中文标题 + 图标 + 排序 - $items = [ - 'fc/category' => ['fc/content', '分类管理', 'fa fa-list', 95], - 'fc/banner' => ['fc/content', '轮播管理', 'fa fa-image', 90], - 'fc/post' => ['fc/content', '信息管理', 'fa fa-file-text', 85], - 'fc/comment' => ['fc/content', '评价管理', 'fa fa-star', 80], - 'fc/report' => ['fc/content', '举报处理', 'fa fa-flag', 75], - 'fc/merchant' => ['fc/user', '商家管理', 'fa fa-building', 95], - 'fc/userext' => ['fc/user', '会员管理', 'fa fa-user-circle', 90], - 'fc/message' => ['fc/user', '消息管理', 'fa fa-bell', 85], - 'fc/order' => ['fc/trade', '订单管理', 'fa fa-file-text-o', 95], - 'fc/plan' => ['fc/trade', '会员套餐', 'fa fa-diamond', 90], - 'fc/financeflow' => ['fc/trade', '资金流水', 'fa fa-money', 85], - 'fc/commission' => ['fc/trade', '佣金明细', 'fa fa-cny', 80], - 'fc/payview' => ['fc/trade', '付费查看记录', 'fa fa-eye', 75], - 'fc/distrelation' => ['fc/dist', '分销关系', 'fa fa-sitemap', 95], - 'fc/withdraw' => ['fc/dist', '提现审核', 'fa fa-credit-card', 90], - 'fc/area' => ['fc/base', '服务区域', 'fa fa-map-o', 95], - 'fc/publish' => ['fc/base', '发布与费用配置', 'fa fa-cny', 85], - 'fc/draft' => ['fc/base', '草稿箱', 'fa fa-folder-o', 90], - ]; - $done = 0; - foreach ($items as $name => $m) { - if (!isset($groupId[$m[0]])) { - continue; - } - $aff = $ruleTable->where('name', $name)->update([ - 'pid' => $groupId[$m[0]], - 'title' => $m[1], - 'icon' => $m[2], - 'weigh' => $m[3], - 'ismenu' => 1, - 'status' => 'normal', - 'updatetime' => $now, - ]); - $done += $aff > 0 ? 1 : 1; - } - - // 4) v1.7.0 新增功能项(按 name upsert:不存在则插入,存在则纠正归属/名称/图标/排序) - // ID 交给数据库自增分配(当前 fa_auth_rule 最大 ID 为 224),不写死 ID,故不会占用已用 ID。 - // 顶级分组「同城业务」下:数据总览置首(weigh 99 > 内容管理 90)、系统设置置末(weigh 40 < 基础数据 50); - // 「交易管理」下:优惠券管理排在会员套餐之后、资金流水之前。 - $groupId['fc'] = $topId; - $newItems = [ - 'fc/dashboard' => ['fc', '数据总览', 'fa fa-dashboard', 99], - 'fc/setting' => ['fc', '小程序设置', 'fa fa-cogs', 40], - 'fc/coupon' => ['fc/trade', '优惠券管理', 'fa fa-ticket', 88], - ]; - $newIds = []; - $inserted = 0; - foreach ($newItems as $name => $m) { - if (!isset($groupId[$m[0]])) { - continue; - } - $row = $ruleTable->where('name', $name)->find(); - $data = [ - 'pid' => $groupId[$m[0]], 'title' => $m[1], 'icon' => $m[2], 'weigh' => $m[3], - 'ismenu' => 1, 'menutype' => 'addtabs', 'status' => 'normal', 'updatetime' => $now, - ]; - if ($row) { - $ruleTable->where('id', $row['id'])->update($data); - $newIds[$name] = (int)$row['id']; - } else { - $data['type'] = 'menu'; - $data['name'] = $name; - $data['url'] = ''; - $data['createtime'] = $now; - $newIds[$name] = (int)$ruleTable->insertGetId($data); - $inserted++; - } - } - - // 5) 新功能项的节点权限(Backend 按「控制器/方法」校验权限,缺节点则非超管会 403) - $nodes = [ - 'fc/dashboard/index' => ['fc/dashboard', '查看'], - 'fc/setting/index' => ['fc/setting', '查看'], - 'fc/coupon/index' => ['fc/coupon', '查看'], - 'fc/coupon/add' => ['fc/coupon', '添加'], - 'fc/coupon/edit' => ['fc/coupon', '编辑'], - 'fc/coupon/del' => ['fc/coupon', '删除'], - 'fc/coupon/multi' => ['fc/coupon', '批量更新'], - ]; - $nodeCount = 0; - foreach ($nodes as $name => $n) { - if (!isset($newIds[$n[0]])) { - continue; - } - $row = $ruleTable->where('name', $name)->find(); - $data = [ - 'pid' => $newIds[$n[0]], 'title' => $n[1], 'icon' => 'fa fa-circle-o', - 'ismenu' => 0, 'weigh' => 0, 'status' => 'normal', 'updatetime' => $now, - ]; - if ($row) { - $ruleTable->where('id', $row['id'])->update($data); - } else { - $data['type'] = 'file'; - $data['name'] = $name; - $data['url'] = ''; - $data['menutype'] = 'addtabs'; - $data['createtime'] = $now; - $ruleTable->insert($data); - } - $nodeCount++; - } - - $output->writeln(' - 顶级 1 / 分组 ' . count($groups) . ' / 功能项 ' . $done); - $output->writeln(' - 新功能项 ' . count($newItems) . ' 个(本次新插入 ' . $inserted . ')+ 权限节点 ' . $nodeCount . ' 个' - . ':数据总览 fc/dashboard、系统设置 fc/setting、优惠券管理 fc/coupon'); - - // 左侧菜单来自 Auth::getSidebar() 的 `__menu__` 缓存:不清这里,本命令刚插入的 - // 「数据总览 / 系统设置 / 优惠券管理」在缓存过期前不会出现在后台侧边栏。 - try { - \think\Cache::rm('__menu__'); - $output->writeln(' - 已清理后台菜单缓存 __menu__(新菜单项即时可见)'); - } catch (\Exception $e) { - // 清理失败不影响主流程:缓存过期后会自动重建 - } + FcMenu::apply($output); } + /** 清空业务表(保留表结构) */ protected function truncateAll() { @@ -580,13 +682,19 @@ class FcInstall extends Command 'area' => "CREATE TABLE IF NOT EXISTS `#P#area` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `name` varchar(32) NOT NULL DEFAULT '', + `province` varchar(32) NOT NULL DEFAULT '' COMMENT '省份', + `city` varchar(32) NOT NULL DEFAULT '' COMMENT '城市', + `district` varchar(32) NOT NULL DEFAULT '' COMMENT '区县(最终服务区域)', + `lat` decimal(10,6) DEFAULT NULL COMMENT '中心纬度(gcj02)', + `lng` decimal(10,6) DEFAULT NULL COMMENT '中心经度(gcj02)', `sort` int(10) NOT NULL DEFAULT '0', `enabled` tinyint(1) NOT NULL DEFAULT '1', `createtime` int(10) DEFAULT NULL, `updatetime` int(10) DEFAULT NULL, - PRIMARY KEY (`id`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-服务区域';", + PRIMARY KEY (`id`), + KEY `province_city` (`province`,`city`), + KEY `enabled` (`enabled`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-服务区域(省/市/区县平铺,区县为服务区域)';", 'post' => "CREATE TABLE IF NOT EXISTS `#P#post` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, @@ -598,7 +706,8 @@ class FcInstall extends Command `media` text COMMENT '媒体JSON数组', `price` decimal(10,2) NOT NULL DEFAULT '0.00', `price_unit` varchar(16) NOT NULL DEFAULT '', - `area` varchar(32) NOT NULL DEFAULT '', + `area` varchar(32) NOT NULL DEFAULT '' COMMENT '区县名称(展示用)', + `district_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '服务区域ID(fc_area.id)', `address` varchar(128) NOT NULL DEFAULT '', `contact` varchar(32) NOT NULL DEFAULT '' COMMENT '联系人', `phone` varchar(20) NOT NULL DEFAULT '' COMMENT '联系电话(完整)', @@ -621,7 +730,8 @@ class FcInstall extends Command PRIMARY KEY (`id`), KEY `category_id` (`category_id`), KEY `user_id` (`user_id`), - KEY `audit_status` (`audit_status`) + KEY `audit_status` (`audit_status`), + KEY `district_id` (`district_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-信息';", 'draft' => "CREATE TABLE IF NOT EXISTS `#P#draft` ( @@ -634,6 +744,8 @@ class FcInstall extends Command `contact` varchar(32) NOT NULL DEFAULT '', `phone` varchar(20) NOT NULL DEFAULT '', `address` varchar(128) NOT NULL DEFAULT '', + `area` varchar(32) NOT NULL DEFAULT '' COMMENT '区县名称', + `district_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '服务区域ID(fc_area.id)', `savedat` int(10) DEFAULT NULL, `createtime` int(10) DEFAULT NULL, `updatetime` int(10) DEFAULT NULL, @@ -698,10 +810,14 @@ class FcInstall extends Command `title` varchar(64) NOT NULL DEFAULT '', `amount` decimal(10,2) NOT NULL DEFAULT '0.00', `pay_type` varchar(16) NOT NULL DEFAULT '', - `status` varchar(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/paid/refunded/closed', + `status` varchar(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/paid/refunding/refunded/closed', `paytime` int(10) DEFAULT NULL, + `origin_amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '优惠前金额', + `coupon_amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '优惠券抵扣金额', + `coupon_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '使用的用户优惠券ID(fc_user_coupon.id)', `refund_amount` decimal(10,2) NOT NULL DEFAULT '0.00', `refund_reason` varchar(255) NOT NULL DEFAULT '', + `out_refund_no` varchar(64) NOT NULL DEFAULT '' COMMENT '微信退款单号(商户侧)', `related_id` int(10) NOT NULL DEFAULT '0' COMMENT '关联业务ID', `createtime` int(10) DEFAULT NULL, `updatetime` int(10) DEFAULT NULL, @@ -734,6 +850,7 @@ class FcInstall extends Command `openid` varchar(64) NOT NULL DEFAULT '' COMMENT '微信openid', `user_type` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1个人2商家', `realname_status` tinyint(1) NOT NULL DEFAULT '0', + `realname` varchar(32) NOT NULL DEFAULT '' COMMENT '收款人真实姓名(提现用)', `is_member` tinyint(1) NOT NULL DEFAULT '0', `member_level` varchar(32) NOT NULL DEFAULT '', `member_expiretime` int(10) DEFAULT NULL, @@ -772,6 +889,7 @@ class FcInstall extends Command `rate` decimal(5,2) NOT NULL DEFAULT '0.00' COMMENT '分佣比例%', `level` tinyint(1) NOT NULL DEFAULT '1', `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0未结算1已结算', + `settle_due` int(10) DEFAULT NULL, `settletime` int(10) DEFAULT NULL, `createtime` int(10) DEFAULT NULL, PRIMARY KEY (`id`), @@ -784,14 +902,21 @@ class FcInstall extends Command `user_id` int(10) unsigned NOT NULL DEFAULT '0', `amount` decimal(10,2) NOT NULL DEFAULT '0.00', `channel` varchar(16) NOT NULL DEFAULT 'wxpay', - `status` varchar(16) NOT NULL DEFAULT 'auditing' COMMENT 'auditing/paid/rejected', + `status` varchar(16) NOT NULL DEFAULT 'auditing' COMMENT 'auditing/paying/paid/failed/rejected', `reason` varchar(255) NOT NULL DEFAULT '' COMMENT '驳回原因', `paytime` int(10) DEFAULT NULL, + `out_bill_no` varchar(32) NOT NULL DEFAULT '' COMMENT '微信商家转账商户单号', + `transfer_status` varchar(16) NOT NULL DEFAULT '' COMMENT 'WAIT_USER_CONFIRM/PROCESSING/SUCCESS/FAIL/CANCELLED', + `transfer_id` varchar(64) NOT NULL DEFAULT '' COMMENT '微信转账单号', + `package_info` varchar(512) NOT NULL DEFAULT '' COMMENT '拉起确认收款页的package', + `fail_reason` varchar(255) NOT NULL DEFAULT '', + `transfer_time` int(10) DEFAULT NULL, `createtime` int(10) DEFAULT NULL, `updatetime` int(10) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `withdraw_no` (`withdraw_no`), - KEY `user_id` (`user_id`) + KEY `user_id` (`user_id`), + KEY `out_bill_no` (`out_bill_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-提现';", 'finance_flow' => "CREATE TABLE IF NOT EXISTS `#P#finance_flow` ( @@ -977,11 +1102,19 @@ class FcInstall extends Command ]); } - /* ---------- 服务区域 ---------- */ - $areas = [['云岩区', 1, 1], ['南明区', 2, 1], ['观山湖区', 3, 1], ['花溪区', 4, 1], ['白云区', 5, 0]]; + /* ---------- 服务区域(省 / 市 / 区县平铺,区县为服务区域) ---------- */ + $areas = [ + ['贵州省', '贵阳市', '云岩区', 1, 1, 26.605, 106.710], + ['贵州省', '贵阳市', '南明区', 2, 1, 26.568, 106.716], + ['贵州省', '贵阳市', '观山湖区', 3, 1, 26.625, 106.625], + ['贵州省', '贵阳市', '花溪区', 4, 1, 26.430, 106.670], + ['贵州省', '贵阳市', '白云区', 5, 0, 26.678, 106.623], + ]; foreach ($areas as $a) { Db::name('fc_area')->insert([ - 'name' => $a[0], 'sort' => $a[1], 'enabled' => $a[2], + 'province' => $a[0], 'city' => $a[1], 'district' => $a[2], + 'lat' => $a[5], 'lng' => $a[6], + 'sort' => $a[3], 'enabled' => $a[4], 'createtime' => $now, 'updatetime' => $now, ]); } @@ -1000,7 +1133,7 @@ class FcInstall extends Command 'createtime' => $now, 'updatetime' => $now, ]); } - $output->writeln(' - 分类8 / 轮播3 / 区域5 / 套餐3'); + $output->writeln(' - 分类8 / 轮播3 / 服务区域5 / 套餐3'); /* ---------- 演示用户(显式ID,与前端 mock 数据集一致) ---------- */ $salt = 'fcseed'; diff --git a/application/admin/command/FcMenu.php b/application/admin/command/FcMenu.php new file mode 100644 index 0000000..972e3cc --- /dev/null +++ b/application/admin/command/FcMenu.php @@ -0,0 +1,309 @@ + [标题, 图标, 权重] */ + const GROUPS = [ + 'console' => ['数据看板', 'fa fa-dashboard', 100], + 'fc/content' => ['信息管理', 'fa fa-file-text-o', 90], + 'fc/user' => ['商家会员', 'fa fa-users', 80], + 'fc/trade' => ['交易财务', 'fa fa-shopping-cart', 70], + 'fc/marketing' => ['运营推广', 'fa fa-share-alt', 60], + 'fc/auth' => ['权限配置', 'fa fa-group', 50], + 'fc/system' => ['系统设置', 'fa fa-cogs', 40], + ]; + + /** 功能菜单:name => [一级菜单, 标题, 图标, 权重] */ + const ITEMS = [ + // 数据看板 + 'fc/dashboard' => ['console', '业务总览', 'fa fa-dashboard', 98], + // 信息管理:信息 + 分类 + 审核 + 'fc/post' => ['fc/content', '信息列表', 'fa fa-file-text', 95], + 'fc/category' => ['fc/content', '分类管理', 'fa fa-list', 90], + 'fc/draft' => ['fc/content', '草稿管理', 'fa fa-folder-o', 85], + 'fc/comment' => ['fc/content', '评价管理', 'fa fa-star', 80], + 'fc/report' => ['fc/content', '举报处理', 'fa fa-flag', 75], + // 商家会员 + 'fc/merchant' => ['fc/user', '商家管理', 'fa fa-building', 95], + 'fc/userext' => ['fc/user', '用户管理', 'fa fa-user-circle', 90], + 'fc/message' => ['fc/user', '消息管理', 'fa fa-bell', 85], + // 交易财务 + 'fc/order' => ['fc/trade', '订单管理', 'fa fa-file-text-o', 95], + 'fc/financeflow' => ['fc/trade', '资金流水', 'fa fa-money', 90], + 'fc/payview' => ['fc/trade', '付费查看', 'fa fa-eye', 85], + 'fc/commission' => ['fc/trade', '佣金明细', 'fa fa-cny', 80], + 'fc/withdraw' => ['fc/trade', '提现审核', 'fa fa-credit-card', 75], + // 运营推广 + 'fc/banner' => ['fc/marketing', '轮播管理', 'fa fa-image', 95], + 'fc/plan' => ['fc/marketing', '会员套餐', 'fa fa-diamond', 90], + 'fc/coupon' => ['fc/marketing', '优惠券', 'fa fa-ticket', 85], + 'fc/distrelation' => ['fc/marketing', '分销关系', 'fa fa-sitemap', 80], + // 权限配置 + 'auth/admin' => ['fc/auth', '管理员', 'fa fa-user', 95], + 'auth/group' => ['fc/auth', '角色管理', 'fa fa-group', 90], + 'auth/rule' => ['fc/auth', '菜单规则', 'fa fa-bars', 85], + 'auth/adminlog' => ['fc/auth', '操作日志', 'fa fa-list-alt', 80], + // 系统设置 + 'general/config' => ['fc/system', '系统配置', 'fa fa-cog', 99], + 'dashboard' => ['fc/system', '平台概况', 'fa fa-dashboard', 95], + 'fc/area' => ['fc/system', '服务区域', 'fa fa-map-o', 90], + 'general/attachment' => ['fc/system', '附件管理', 'fa fa-file-image-o', 80], + 'general/profile' => ['fc/system', '个人资料', 'fa fa-user', 70], + 'addon' => ['fc/system', '插件管理', 'fa fa-rocket', 60], + ]; + + /** 已并入系统配置页、不再单独显示的历史菜单 */ + const RETIRED = ['fc/setting', 'fc/publish/index']; + + /** 权限节点:name => [上级菜单, 标题] */ + const NODES = [ + 'fc/dashboard/index' => ['fc/dashboard', '查看'], + 'fc/coupon/index' => ['fc/coupon', '查看'], + 'fc/coupon/add' => ['fc/coupon', '添加'], + 'fc/coupon/edit' => ['fc/coupon', '编辑'], + 'fc/coupon/del' => ['fc/coupon', '删除'], + 'fc/coupon/multi' => ['fc/coupon', '批量更新'], + ]; + + /** 需要整体下线的 FastAdmin 内置菜单(公众号模块 / 框架会员模块) */ + const OFFLINE = [ + 'wechat' => '公众号模块与本项目小程序无关,下线', + 'user' => '框架会员模块与「商家会员 → 用户管理」重复,下线', + ]; + + /** 下线菜单统一改为 4 字中文,避免规则列表里出现英文/超长标题 */ + const OFFLINE_TITLES = [ + 'user' => '会员管理', + 'user/user' => '会员管理', + 'user/group' => '会员分组', + 'user/rule' => '会员规则', + 'wechat' => '微信管理', + 'wechat/autoreply' => '自动回复', + 'wechat/response' => '资源管理', + 'wechat/config' => '配置管理', + 'wechat/menu' => '菜单管理', + ]; + + /** 需要清理的演示菜单 */ + const DEMO = ['category']; + + protected function configure() + { + $this->setName('fc:menu') + ->setDescription('重建同城业务后台菜单结构(幂等)'); + } + + protected function execute(Input $input, Output $output) + { + self::apply($output); + } + + /** + * 应用目标菜单树 + * + * @param Output|null $output + * @return void + */ + public static function apply(Output $output = null) + { + $now = time(); + $table = Db::name('auth_rule'); + + // 0) 旧分组更名:分销管理 → 运营推广,避免按 name 定位时新建一个空分组 + if (!$table->where('name', 'fc/marketing')->find()) { + $old = $table->where('name', 'fc/dist')->find(); + if ($old) { + $table->where('id', $old['id'])->update([ + 'name' => 'fc/marketing', + 'updatetime' => $now, + ]); + } + } + + // 1) 一级菜单 + $groupIds = []; + foreach (self::GROUPS as $name => $item) { + $groupIds[$name] = self::put($table, $name, [ + 'type' => 'menu', + 'pid' => 0, + 'title' => $item[0], + 'icon' => $item[1], + 'ismenu' => 1, + 'weigh' => $item[2], + ], $now); + } + + // 2) 功能菜单 + $itemIds = []; + foreach (self::ITEMS as $name => $item) { + $parent = $groupIds[$item[0]] ?? ($groupIds['fc/system'] ?? 0); + $itemIds[$name] = self::put($table, $name, [ + 'type' => 'file', + 'pid' => $parent, + 'title' => $item[1], + 'icon' => $item[2], + 'ismenu' => 1, + 'weigh' => $item[3], + ], $now); + } + + // 3) 权限节点(只补缺,不改业务已用的标题) + $nodeCount = 0; + foreach (self::NODES as $name => $node) { + $parent = $itemIds[$node[0]] ?? ($groupIds[$node[0]] ?? 0); + if (!$parent) { + continue; + } + self::put($table, $name, [ + 'type' => 'file', + 'pid' => $parent, + 'title' => $node[1], + 'icon' => 'fa fa-circle-o', + 'ismenu' => 0, + 'weigh' => 0, + ], $now); + $nodeCount++; + } + + // 4) 菜单页说明(build_heading 读取 auth_rule.remark,而不是控制器注释) + $table->where('name', 'auth/rule')->update([ + 'remark' => '左侧菜单与权限节点都在这里维护;修改后左侧导航自动刷新,删除菜单会连同其子级一起删除', + ]); + + // 5) 演示菜单清理 + $removed = 0; + foreach (self::DEMO as $name) { + $row = $table->where('name', $name)->find(); + if (!$row) { + continue; + } + $ids = array_merge([(int)$row['id']], $table->where('pid', $row['id'])->column('id')); + $removed += $table->where('id', 'in', $ids)->delete(); + } + + // 6) 下线框架/公众号菜单(父级隐藏后,子级不会再出现在侧边栏) + $offline = 0; + foreach (array_keys(self::OFFLINE) as $name) { + $row = $table->where('name', $name)->find(); + if (!$row) { + continue; + } + $table->where('id', $row['id'])->update(['status' => 'hidden', 'updatetime' => $now]); + $table->where('pid', $row['id'])->update(['status' => 'hidden', 'updatetime' => $now]); + $offline++; + } + foreach (self::OFFLINE_TITLES as $name => $title) { + $table->where('name', $name)->update(['title' => $title]); + } + + // 6.1) 已并入系统配置页的历史菜单,不再单独显示 + foreach (self::RETIRED as $name) { + $table->where('name', $name)->update(['status' => 'hidden', 'updatetime' => $now]); + } + + // 7) 清理已搬空的历史分组(同城业务 / 基础数据 / 常规管理 / 权限管理) + $emptyRemoved = 0; + foreach (['fc', 'fc/base', 'general', 'auth'] as $name) { + $row = $table->where('name', $name)->find(); + if ($row && !$table->where('pid', $row['id'])->count()) { + $table->where('id', $row['id'])->delete(); + $emptyRemoved++; + } + } + + // 8) 菜单缓存 + try { + Cache::rm('__menu__'); + } catch (\Exception $e) { + // 缓存清理失败不影响菜单数据,过期后会自动重建 + } + + if ($output) { + // 菜单命名规范:不超过 5 个汉字 + $tooLong = []; + foreach (self::GROUPS as $group) { + if (mb_strlen($group[0], 'UTF-8') > 5) { + $tooLong[] = $group[0]; + } + } + foreach (self::ITEMS as $item) { + if (mb_strlen($item[1], 'UTF-8') > 5) { + $tooLong[] = $item[1]; + } + } + + $output->writeln(' - 一级菜单 ' . count(self::GROUPS) . ' / 功能菜单 ' . count(self::ITEMS)); + $output->writeln(' - 权限节点 ' . $nodeCount . ' 个;清理演示菜单 ' . $removed . ' 条;下线框架菜单 ' . $offline . ' 组;移除空分组 ' . $emptyRemoved . ' 个'); + if ($tooLong) { + $output->writeln(' - 警告:以下菜单超过 5 个汉字:' . implode('、', $tooLong)); + } + $output->writeln(' - 已清理后台菜单缓存 __menu__'); + } + } + + /** + * 按 name 写入一条规则:存在则更新,不存在则插入 + * + * @param \think\db\Query $table + * @param string $name + * @param array $data + * @param int $now + * @return int + */ + protected static function put($table, $name, array $data, $now) + { + $data = array_merge([ + 'type' => 'file', + 'pid' => 0, + 'title' => $name, + 'icon' => 'fa fa-circle-o', + 'url' => '', + 'ismenu' => 1, + 'menutype' => 'addtabs', + 'weigh' => 0, + 'status' => 'normal', + ], $data); + $data['updatetime'] = $now; + + $row = $table->where('name', $name)->find(); + if ($row) { + $table->where('id', $row['id'])->update($data); + return (int)$row['id']; + } + + $data['name'] = $name; + $data['createtime'] = $now; + return (int)$table->insertGetId($data); + } +} diff --git a/application/admin/command/FcSeedPosts.php b/application/admin/command/FcSeedPosts.php new file mode 100644 index 0000000..7e4ea68 --- /dev/null +++ b/application/admin/command/FcSeedPosts.php @@ -0,0 +1,464 @@ + [昵称, 手机号] */ + const USERS = [ + 1001 => ['同城小助手', '13800001111'], + 1002 => ['小王同学', '15033443344'], + 1003 => ['洁净到家', '13156785678'], + 2001 => ['顺捷物流', '13988888888'], + 2002 => ['安家房产', '13566666666'], + 2003 => ['骑行爱好者', '13712341234'], + 2004 => ['车行天下', '13800000000'], + 2005 => ['洁净到家', '13156785678'], + 2006 => ['小王同学', '15033443344'], + 2007 => ['优选二手车', '13700000000'], + ]; + + /** 区域中心坐标(gcj02 近似值,用于首页「附近」排序演示) */ + const AREA_COORD = [ + '云岩区' => [26.605, 106.710], + '南明区' => [26.568, 106.716], + '观山湖区' => [26.625, 106.625], + '花溪区' => [26.430, 106.670], + ]; + + protected function configure() + { + $this->setName('fc:seed-posts') + ->setDescription('清理不规范测试数据,按分类重建信息演示数据'); + } + + protected function execute(Input $input, Output $output) + { + $now = time(); + $prefix = \think\Config::get('database.prefix'); + $output->writeln('==> 清理不规范测试数据'); + $this->clean(); + + $data = $this->posts(); + $owners = $this->owners(); + + Db::startTrans(); + try { + $id = 101; + $seq = 0; + $stat = []; + foreach ($data as $categoryId => $rows) { + $stat[$categoryId] = 0; + foreach ($rows as $i => $row) { + $uid = $owners[$categoryId][$i]; + $user = self::USERS[$uid]; + $audit = $this->auditStatus($categoryId, $i); + $paid = $audit === 1 && $categoryId !== 8 && $i % 4 === 1; + $views = 160 + (($categoryId * 977 + $i * 613) % 3200); + $likes = max(2, (int)($views / 48)); + $areas = $this->coord($row[4], $i); + $created = $now - (int)((0.15 + $i * 0.41 + $categoryId * 0.13) * 86400); + $media = $this->mediaPaths($categoryId, $i); + + $areaId = $this->areaId($row[5]); + + Db::name('fc_post')->insert([ + 'id' => $id, + 'user_id' => $uid, + 'category_id' => $categoryId, + 'title' => $row[1], + 'content' => $row[2], + 'cover' => $media[0], + 'media' => json_encode($media, JSON_UNESCAPED_UNICODE), + 'price' => $row[3], + 'price_unit' => $row[4], + 'area' => $row[5], + 'district_id' => $areaId, + 'address' => $row[6], + 'lat' => $areas[0], + 'lng' => $areas[1], + 'contact' => $row[0], + 'phone' => $user[1], + 'wechat' => 'gyfc' . $id, + 'tags' => $row[7], + 'is_paid' => $paid ? 1 : 0, + 'amount' => $paid ? 5 : 0, + 'pay_type' => $paid ? ($i % 2 === 1 ? 'wxpay' : 'balance') : '', + 'audit_status' => $audit, + 'audit_reason' => $audit === 2 ? '信息涉嫌重复发布' : '', + 'views' => $audit === 1 ? $views : 0, + 'likes' => $audit === 1 ? $likes : 0, + 'comment_count' => 0, + 'status' => 1, + 'is_top' => $this->isTop($categoryId, $i) ? 1 : 0, + 'distance' => 0, + 'createtime' => $created, + 'updatetime' => $created, + ]); + + $id++; + $seq++; + if ($audit === 1) { + $stat[$categoryId]++; + } + } + } + $output->writeln(' - 信息 ' . $seq . ' 条(已更新 ID 101-' . ($id - 1) . ')'); + + $this->seedComments($now); + $this->seedDrafts($now); + $this->seedReports($now); + $this->syncCounts(); + + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + $output->writeln(' [x] 重建失败:' . $e->getMessage()); + return; + } + + Db::execute('ALTER TABLE `' . $prefix . 'fc_post` AUTO_INCREMENT = 165'); + + foreach ($stat as $categoryId => $count) { + $name = Db::name('fc_category')->where('id', $categoryId)->value('name'); + $output->writeln(' - ' . $name . ':' . $count . ' 条已发布'); + } + $output->writeln('==> 完成'); + } + + /** 清理内容类测试数据(保留用户、订单、佣金、配置) */ + protected function clean() + { + Db::name('fc_comment')->where('id', '>', 0)->delete(); + Db::name('fc_favorite')->where('id', '>', 0)->delete(); + Db::name('fc_pay_view')->where('id', '>', 0)->delete(); + Db::name('fc_draft')->where('id', '>', 0)->delete(); + Db::name('fc_report')->where('id', '>', 0)->delete(); + Db::name('fc_post')->where('id', '>', 0)->delete(); + Db::name('fc_area')->where('district', '接口验证区')->delete(); + Db::name('fc_message')->where('title', 'like', '%接口验证%')->whereOr('content', 'like', '%接口验证%')->delete(); + $this->cleanMockUsers(); + } + + /** 清理历史自动化脚本遗留的临时用户及其关联数据 */ + protected function cleanMockUsers() + { + $ids = Db::name('user') + ->where('username', 'like', 'test\\_dist\\_%') + ->whereOr('username', 'like', 'mock\\_test\\_dist\\_%') + ->column('id'); + if (!$ids) { + return; + } + Db::name('fc_user_ext')->where('user_id', 'in', $ids)->delete(); + Db::name('fc_dist_relation')->where('user_id', 'in', $ids)->whereOr('parent_id', 'in', $ids)->delete(); + Db::name('fc_order')->where('user_id', 'in', $ids)->delete(); + Db::name('fc_commission')->where('user_id', 'in', $ids)->whereOr('from_user_id', 'in', $ids)->delete(); + Db::name('fc_withdraw')->where('user_id', 'in', $ids)->delete(); + Db::name('fc_message')->where('user_id', 'in', $ids)->delete(); + Db::name('user')->where('id', 'in', $ids)->delete(); + } + + /** 每个分类下的发布账号,按信息顺序一一对应 */ + protected function owners() + { + return [ + 1 => [2001, 1002, 2002, 2005, 1002, 2001, 1003, 2004], + 2 => [2002, 2002, 2002, 2002, 1001, 2002, 2002, 1001], + 3 => [2003, 2006, 1002, 2005, 1003, 1001, 2003, 2006], + 4 => [2004, 2007, 2004, 2007, 2004, 2007, 2003, 2007], + 5 => [2005, 1003, 1001, 1003, 2005, 1003, 1002, 1001], + 6 => [1002, 1003, 1001, 1002, 1003, 1001, 1002, 1003], + 7 => [1003, 2005, 1003, 2005, 1003, 2005, 1003, 2005], + 8 => [1002, 2003, 1001, 1002, 2003, 1001, 1002, 2003], + ]; + } + + /** 审核状态:默认通过;少量待审/驳回用于验证后台流程 */ + protected function auditStatus($categoryId, $index) + { + $pending = [[4, 6], [8, 7]]; + $rejected = [[3, 5]]; + if (in_array([$categoryId, $index], $pending, true)) { + return 0; + } + if (in_array([$categoryId, $index], $rejected, true)) { + return 2; + } + return 1; + } + + /** 置顶:每个分类最多一条,制造首页头部内容 */ + protected function isTop($categoryId, $index) + { + $tops = [[1, 0], [3, 1], [5, 0], [7, 1]]; + return in_array([$categoryId, $index], $tops, true); + } + + /** 区县名称 → 服务区域ID(fc_area.id) */ + protected function areaId($area) + { + static $cache = []; + if (isset($cache[$area])) { + return $cache[$area]; + } + $row = Db::name('fc_area')->where('district', $area)->find(); + return $cache[$area] = $row ? (int)$row['id'] : 0; + } + + /** 区域中心 + 稳定偏移,避免所有信息坐标完全重合 */ + protected function coord($area, $index) + { + $base = isset(self::AREA_COORD[$area]) ? self::AREA_COORD[$area] : self::AREA_COORD['云岩区']; + $lat = $base[0] + (($index % 5) - 2) * 0.0055; + $lng = $base[1] + ((int)($index / 2) - 2) * 0.0065; + return [round($lat, 6), round($lng, 6)]; + } + + /** + * 信息配图:每条信息使用 1 张与内容匹配的真实照片作为封面和详情图。 + * 图片存放在 public/static/posts/,随代码仓库部署,不依赖第三方外链。 + */ + protected function mediaPaths($categoryId, $index) + { + $primary = $this->primaries()[$categoryId][$index]; + return ['/static/posts/' . $primary . '.jpg']; + } + + /** 每条信息的首图(与 posts() 的顺序一一对应) */ + protected function primaries() + { + return [ + 1 => ['job-warehouse', 'job-cafe', 'job-office', 'job-cleaning', 'job-warehouse', 'job-delivery', 'job-classroom', 'job-handshake'], + 2 => ['house-living1', 'house-living3', 'house-office', 'house-model', 'house-living2', 'house-living4', 'house-exterior', 'job-cafe'], + 3 => ['goods-bike', 'goods-ipad', 'goods-dining', 'goods-fridge', 'goods-stroller', 'goods-laptop', 'goods-treadmill', 'goods-desk'], + 4 => ['car-sedan-white', 'car-compact-blue', 'car-moto', 'car-hatch-red', 'car-coupe-blue', 'car-suv-white', 'goods-bike', 'car-sedan-dark'], + 5 => ['svc-spray', 'svc-keys', 'svc-code', 'svc-wrench', 'svc-moving', 'svc-drill', 'svc-camera', 'svc-night'], + 6 => ['edu-art', 'edu-books', 'edu-shelf', 'edu-guitar', 'edu-laptop', 'edu-class', 'car-sedan-white', 'edu-shelf'], + 7 => ['clean-window', 'clean-bath', 'clean-baby', 'clean-kitchen', 'clean-kitchen', 'clean-window', 'clean-elder', 'clean-vacuum'], + 8 => ['friend-hike', 'friend-badminton', 'friend-books', 'friend-run', 'friend-camera', 'friend-bike', 'friend-board', 'friend-group'], + ]; + } + + /** 信息数据集:联系人 / 标题 / 内容 / 价格 / 单位 / 区域 / 地址 / 标签 */ + protected function posts() + { + return [ + // 招聘求职 + 1 => [ + ['杨经理', '急招快递分拣员(日结)', '站点直招,日结 180-220 元,包工作餐,当天结算。能吃苦、手脚麻利即可,年龄 18-50 岁。', 0, '面议', '云岩区', '贵阳北站物流园', '日结,包吃'], + ['李店长', '奶茶店店员(包住)', '花果园商圈奶茶店招店员,负责点单和出杯。早班 10 点到 18 点,月休 4 天,包住,有提成。', 3800, '元/月', '南明区', '花果园购物中心', '包住,有提成'], + ['周主管', '行政文员(双休)', '负责合同整理、客户接待和日常表格。会基本办公软件,做事细心,转正后买五险。', 4200, '元/月', '观山湖区', '金融城', '双休,五险'], + ['王师傅', '招保洁员 5 名', '承接小区和写字楼保洁,公司派单,不用自己找客户。多劳多得,工资月结。', 3000, '元/月', '云岩区', '喷水池', '多劳多得,月结'], + ['张店长', '超市理货员(兼职)', '每天下午 4 点到 9 点,负责补货和整理货架。适合学生和宝妈,工资周结。', 18, '元/小时', '南明区', '花果园', '兼职,周结'], + ['刘站长', '外卖骑手(可日结)', '自带电动车优先,站点提供充电和休息区。跑单时间自由,多跑多得。', 6000, '元/月', '观山湖区', '世纪城', '日结,时间自由'], + ['陈老师', '幼儿园保育员', '负责班级卫生和孩子日常照料,有保育员证优先。周末双休,包午餐。', 3200, '元/月', '花溪区', '贵州大学附近', '双休,包午餐'], + ['吴经理', '汽车销售顾问', '负责到店客户接待和车型介绍,有销售经验优先。底薪加提成,上不封顶。', 5000, '元/月', '观山湖区', '金阳客车站旁', '底薪加提成,五险'], + ], + // 房产租售 + 2 => [ + ['刘经理', '整租·未来方舟精装一室一厅', '家电齐全,拎包入住,近地铁 2 号线,押一付一,可短租。', 1800, '元/月', '南明区', '未来方舟 B 区', '精装,近地铁'], + ['王女士', '花果园两室一厅 南北通透', '中间楼层,采光好,家具家电全带,随时看房。', 2200, '元/月', '南明区', '花果园', '采光好,随时看房'], + ['周先生', '观山湖金融城写字楼出租', '80 平,独立办公室,带中央空调和前台,适合小团队。', 45, '元/㎡/月', '观山湖区', '金融城', '写字楼,带空调'], + ['李女士', '云岩区学区房出售 满五唯一', '两室一厅 78 平,划片实验小学,业主置换急售。', 98, '万元', '云岩区', '喷水池', '学区房,满五唯一'], + ['小杨', '花溪大学城单间合租', '独立卫生间,室友都是上班族,环境安静,押一付三。', 650, '元/月', '花溪区', '贵州大学北门', '合租,独卫'], + ['刘经理', '世纪城三室两厅 拎包入住', '130 平,电梯房,家电齐全,适合一家人住。', 2600, '元/月', '观山湖区', '世纪城', '电梯房,家电齐全'], + ['陈先生', '甲秀楼旁两室出售', '65 平,位置方便,出门就是菜场和公交站,价格可谈。', 62, '万元', '南明区', '甲秀楼', '位置好,可议价'], + ['小杨', '观山湖门面转让(可空转)', '临街 40 平,带基础装修,适合小吃和便利店,接手就能营业。', 3500, '元/月', '观山湖区', '奥体中心', '临街,可空转'], + ], + // 二手闲置 + 3 => [ + ['小周', '九成新山地自行车 转让', '骑行不到 300 公里,27 速,原价 1599,现 680 出,可小刀,同城自提。', 680, '元', '观山湖区', '世纪城', '自提,9成新'], + ['小王', 'iPad 2021 64G 深空灰', '学生自用,无磕碰,配件齐全,支持当面验机。', 1450, '元', '观山湖区', '贵阳奥体中心', '验机,无磕碰'], + ['小林', '实木餐桌椅 四人位', '搬家带不走,桌面无划痕,椅子结实,自提价。', 420, '元', '南明区', '花果园', '自提,实木'], + ['王姐', '美的双门冰箱 260L', '用了两年,制冷正常,因为换大冰箱所以出,可上门看货。', 550, '元', '云岩区', '喷水池', '可看货,制冷正常'], + ['小陈', '好孩子婴儿推车', '宝宝大了用不上,可坐可躺,折叠方便,清洗干净。', 180, '元', '花溪区', '花溪公园', '可折叠,已清洗'], + ['小杨', '联想拯救者游戏本', '16G 内存,512G 固态,打游戏不卡,电池健康,带原装充电器。', 3200, '元', '南明区', '花果园', '游戏本,带充电器'], + ['小周', '家用跑步机 九成新', '买来跑了不到十次,可折叠收纳,同城自提。', 800, '元', '观山湖区', '观山湖公园', '自提,可折叠'], + ['小王', '书架加办公桌一套', '租房换地方,便宜出,书桌 1.2 米,书架五层。', 200, '元', '观山湖区', '世纪城', '一套,便宜出'], + ], + // 车辆买卖 + 4 => [ + ['吴经理', '2019 款奥迪 A4L 一手车', '白色车身,车况好,无事故无泡水,4S 店保养记录齐全,支持第三方检测。', 12.8, '万元', '云岩区', '金阳客车站旁', '无事故,可检测'], + ['优选二手车', '城市代步小车 2022 款', '车身小巧好停车,续航够日常通勤,跑了 1.2 万公里,手续齐全。', 2.6, '万元', '观山湖区', '世纪城', '代步,手续齐全'], + ['吴经理', '雅马哈踏板摩托车', '125 排量,车况好,油耗低,带牌照一起出,可试车。', 4500, '元', '南明区', '花果园', '可试车,带牌'], + ['老李', '二手代步小车 省油好开', '个人一手车,1.5 排量,市区代步省油,保养刚做,可试车。', 1.8, '万元', '云岩区', '贵阳北站', '省油,可试车'], + ['吴经理', '宝马 4 系 2018 款', '蓝色车身,车况好,无事故,支持第三方检测,可分期。', 15.8, '万元', '观山湖区', '金融城', '无事故,可分期'], + ['优选二手车', '家用七座 SUV 车况好', '七座大空间,适合一家人出行,无事故,保养记录齐全,可检测。', 8.8, '万元', '南明区', '龙洞堡', '7座,可检测'], + ['小周', '电助力山地车', '36V 电池,助力续航 50 公里,上下班代步很省力。', 1600, '元', '观山湖区', '奥体中心', '助力,省力'], + ['老李', '宝马 3 系 2019 款', '车况好,内饰干净,无事故,支持第三方检测,可分期。', 12.8, '万元', '花溪区', '石板物流园', '无事故,可分期'], + ], + // 本地服务 + 5 => [ + ['王师傅', '专业空调清洗加氟上门', '10 年老师傅,清洗 60 元一台,加氟 80 元起,不满意不收费。', 60, '元/台', '南明区', '花果园', '上门,不满意不收费'], + ['陈师傅', '开锁换锁 24 小时上门', '防盗门、汽车锁都能开,换锁芯明码标价,半夜也能来。', 80, '元起', '云岩区', '喷水池', '24小时,明码标价'], + ['小杨', '电脑维修 重装系统', '蓝屏、开不了机、清灰换硅脂都能修,先检测后报价。', 50, '元起', '南明区', '花果园', '先检测,上门'], + ['陈师傅', '管道疏通 高压清洗', '厨房下水、马桶、地漏疏通,不通不收费,节假日不休。', 100, '元起', '云岩区', '黔灵山公园', '不通不收费,节假日不休'], + ['王师傅', '搬家拉货 小面包车', '市区内 120 元起,可帮忙搬下楼,费用提前说清楚。', 120, '元起', '观山湖区', '世纪城', '搬家,价格透明'], + ['陈师傅', '防水补漏 免费上门检测', '屋顶、卫生间、外墙漏水都能做,先看现场再报价,质保两年。', 0, '面议', '花溪区', '花溪公园', '免费检测,质保两年'], + ['小林', '摄影跟拍 婚礼活动', '婚礼、生日、毕业照都能拍,当天出精修图,可约周末。', 800, '元/场', '观山湖区', '观山湖公园', '跟拍,当天出图'], + ['小杨', '酒后代驾 市区内', '晚上 8 点到凌晨 2 点接单,市区 39 元起,安全送到家。', 39, '元起', '南明区', '甲秀楼', '夜间,安全送达'], + ], + // 教育培训 + 6 => [ + ['李老师', '少儿美术体验课 9.9 元', '4 到 12 岁都能来,画材全包,先体验再决定,周末可约。', 9.9, '元', '观山湖区', '世纪城', '体验课,画材全包'], + ['张老师', '初三数学一对一辅导', '在职老师,带过三届毕业班,先测水平再定计划,可上门。', 150, '元/课时', '南明区', '花果园', '一对一,可上门'], + ['招生办', '成人高考报名咨询', '大专本科都能报,流程有人指导,考前有资料,不收中介费。', 0, '面议', '云岩区', '喷水池', '学历提升,免费咨询'], + ['阿杰老师', '吉他尤克里里 零基础班', '小班教学,一人一把琴,学会三首歌再续课,晚上和周末上课。', 80, '元/课时', '观山湖区', '奥体中心', '零基础,小班'], + ['李老师', '少儿编程体验课', '图形化编程入门,边玩边学,适合 7 到 12 岁,电脑教室上课。', 19.9, '元', '花溪区', '花溪公园', '体验课,边玩边学'], + ['招生办', '普通话考级培训', '针对二乙二甲考试,小班纠音,考前模拟测试,不过可重听。', 680, '元', '云岩区', '黔灵山公园', '考级,可重听'], + ['王教练', '驾校 C1 报名', '本地练车,随到随学,科目一到科目四全程带,费用一次交清。', 2680, '元', '南明区', '龙洞堡', '一费制,随到随学'], + ['张老师', '英语四六级冲刺班', '每周三次晚课,讲真题和作文模板,适合基础一般的大学生。', 499, '元', '花溪区', '贵州大学', '冲刺班,真题讲解'], + ], + // 家政保洁 + 7 => [ + ['陈姐', '日常保洁 3 小时', '扫地拖地、擦灰、厨房卫生间清洁,自带工具,满意再付款。', 120, '元/次', '南明区', '花果园', '自带工具,满意付款'], + ['王师傅', '新房开荒保洁', '装修后除尘除胶,玻璃和柜子里外都擦,按面积算,量大优惠。', 5, '元/㎡', '观山湖区', '世纪城', '开荒,按面积'], + ['陈姐', '月嫂育儿嫂 可预约', '带过 30 多个宝宝,会做月子餐,有健康证,可先面试。', 8000, '元/月', '云岩区', '喷水池', '有健康证,可面试'], + ['王师傅', '油烟机洗衣机深度清洗', '拆洗不伤机器,清洗完当场试机,两台一起更划算。', 199, '元', '南明区', '花果园', '拆洗,当场试机'], + ['陈姐', '钟点工 做饭打扫', '每天两小时,做饭加简单收拾,口味可以按家里习惯来。', 35, '元/小时', '观山湖区', '金融城', '做饭,按小时'], + ['王师傅', '高层玻璃清洗', '内外都能擦,带安全绳,雨天不接单,提前一天预约。', 150, '元起', '观山湖区', '奥体中心', '高层,带安全绳'], + ['陈姐', '老人陪护 住家', '照顾能自理的老人,做饭陪着说话,有护理经验,可长期。', 4500, '元/月', '花溪区', '花溪公园', '住家,有护理经验'], + ['王师傅', '地毯清洗 上门', '公司办公室和家庭地毯都能洗,洗完当天能干,按平方计价。', 8, '元/㎡', '云岩区', '贵阳北站', '上门,当天干'], + ], + // 同城交友 + 8 => [ + ['小林', '周末爬山搭子 黔灵山', '周六早上 8 点黔灵山门口集合,走慢线,新手也能来,自带水。', 0, '面议', '云岩区', '黔灵山公园', '爬山,新手友好'], + ['小周', '羽毛球约球 每周三晚', '观山湖体育馆,水平一般,主要活动身体,场地费 AA。', 0, '面议', '观山湖区', '奥体中心', '羽毛球,AA'], + ['小杨', '贵阳本地读书会 招募', '每月一本书,线下聊两小时,不限年龄职业,安静参与就好。', 0, '面议', '南明区', '甲秀楼', '读书会,每月一次'], + ['小林', '90 后女生 找运动搭子', '平时跑步、骑车都可以,周末有空,希望住得近一点,互相监督。', 0, '面议', '观山湖区', '观山湖公园', '运动,住得近'], + ['小周', '摄影爱好者 周末扫街', '喜欢拍老街和人文,器材不限,手机也能一起玩,拍完一起吃饭。', 0, '面议', '云岩区', '喷水池', '摄影,手机也行'], + ['小杨', '骑行群招募 周末环湖', '休闲骑,速度不快,观山湖集合,骑完喝茶,安全第一。', 0, '面议', '观山湖区', '观山湖公园', '骑行,休闲'], + ['小林', '桌游局 缺 2 人', '周六下午玩狼人杀和剧本,新手有人带,场地在花果园,费用 AA。', 0, '面议', '南明区', '花果园', '桌游,新手可来'], + ['小周', '单身青年联谊活动 报名', '25 到 35 岁,工作稳定,活动有游戏和自由聊天,提前报名。', 0, '面议', '南明区', '花果园', '联谊,提前报名'], + ], + ]; + } + + /** 评价:post_id => [用户ID, 评分, 内容, 商家回复] */ + protected function seedComments($now) + { + $comments = [ + 101 => [[1002, 5, '联系很及时,当天就上岗了。', ''], [2003, 5, '工资日结是真的,站长人也不错。', '']], + 102 => [[1003, 5, '店里氛围挺好,排班也合理。', '']], + 109 => [[1003, 4, '房子不错,房东好说话。', '感谢支持,欢迎续租~']], + 110 => [[1002, 5, '采光确实好,看房很方便。', '']], + 117 => [[2006, 5, '车况和描述一致,交易很顺利。', ''], [1001, 4, '卖家很爽快,还送了车锁。', '']], + 118 => [[2003, 5, '当面验过机,没有问题。', '']], + 125 => [[2003, 5, '检测报告齐全,买得放心。', '']], + 126 => [[1002, 4, '代步够用,充电也方便。', '']], + 133 => [[1001, 5, '师傅上门很准时,清洗得很干净。', '感谢认可,有需要随时联系。']], + 134 => [[2006, 5, '半夜叫的开锁,十分钟就到了。', '']], + 141 => [[1003, 5, '孩子很喜欢,老师也有耐心。', '']], + 142 => [[2003, 4, '讲得清楚,孩子愿意学。', '']], + 149 => [[1001, 5, '打扫得仔细,下次还约。', '']], + 150 => [[1003, 5, '开荒做得很干净,柜子里面都擦了。', '']], + 157 => [[1003, 5, '上周去过,路线轻松,人也很好。', '']], + 158 => [[1001, 5, '水平差不多,打得很尽兴。', '']], + ]; + + $id = 5001; + $i = 0; + foreach ($comments as $postId => $rows) { + foreach ($rows as $row) { + $t = $now - (int)((0.4 + $i * 0.23) * 86400); + Db::name('fc_comment')->insert([ + 'id' => $id, + 'post_id' => $postId, + 'user_id' => $row[0], + 'score' => $row[1], + 'content' => $row[2], + 'images' => json_encode([], JSON_UNESCAPED_UNICODE), + 'reply' => $row[3], + 'replytime' => $row[3] !== '' ? $t + 3600 : null, + 'top' => 0, + 'status' => 1, + 'createtime' => $t, + 'updatetime' => $t, + ]); + $id++; + $i++; + } + } + } + + /** 草稿:保留两条未完成信息,用于验证草稿箱 */ + protected function seedDrafts($now) + { + $rows = [ + [1001, 2, '转租单间(草稿)', '房子还在收拾,晚点补照片。'], + [1003, 7, '保洁套餐(草稿)', '想做一个三次卡的套餐,价格还没定。'], + ]; + $t = $now - 7200; + foreach ($rows as $row) { + Db::name('fc_draft')->insert([ + 'user_id' => $row[0], + 'category_id' => $row[1], + 'title' => $row[2], + 'content' => $row[3], + 'media' => json_encode([], JSON_UNESCAPED_UNICODE), + 'contact' => '', + 'phone' => '', + 'address' => '', + 'savedat' => $t, + 'createtime' => $t, + 'updatetime' => $t, + ]); + $t -= 1800; + } + } + + /** 举报:两条不同状态,便于后台处理演示 */ + protected function seedReports($now) + { + $rows = [ + ['post', 117, '价格与描述不一致', 1002, 'pending', '', 0.6], + ['comment', 5002, '评论里留了外部联系方式', 2006, 'handled', '已删除违规评论', 3], + ]; + foreach ($rows as $row) { + $t = $now - (int)($row[6] * 86400); + Db::name('fc_report')->insert([ + 'target_type' => $row[0], + 'target_id' => $row[1], + 'reason' => $row[2], + 'reporter_id' => $row[3], + 'status' => $row[4], + 'remark' => $row[5], + 'createtime' => $t, + 'updatetime' => $t, + ]); + } + } + + /** 回写分类信息量 / 评价数,保证列表计数与真实数据一致 */ + protected function syncCounts() + { + Db::execute('UPDATE `' . \think\Config::get('database.prefix') . 'fc_post` p SET p.`comment_count` = (SELECT COUNT(*) FROM `' . \think\Config::get('database.prefix') . 'fc_comment` c WHERE c.`post_id` = p.`id` AND c.`status` = 1)'); + + foreach ([1, 2, 3, 4, 5, 6, 7, 8] as $categoryId) { + $count = Db::name('fc_post') + ->where('category_id', $categoryId) + ->where('audit_status', 1) + ->where('status', 1) + ->whereNull('deletetime') + ->count(); + Db::name('fc_category')->where('id', $categoryId)->update(['post_count' => $count]); + } + } +} diff --git a/application/admin/command/FcSettle.php b/application/admin/command/FcSettle.php new file mode 100644 index 0000000..21ce7fb --- /dev/null +++ b/application/admin/command/FcSettle.php @@ -0,0 +1,82 @@ +setName('fc:settle') + ->setDescription('分销佣金到期结算 / 关闭超时订单 / 同步转账状态'); + } + + protected function execute(Input $input, Output $output) + { + $now = time(); + + /* 1. 佣金到期结算 */ + $settled = Db::name('fc_commission') + ->where('status', 0) + ->where('settle_due', '<=', $now) + ->update([ + 'status' => 1, + 'settletime' => $now, + ]); + $output->writeln('==> 佣金结算:' . $settled . ' 笔'); + + /* 2. 关闭超时未支付订单(24 小时) */ + $expired = Db::name('fc_order') + ->where('status', 'pending') + ->where('createtime', '<', $now - 86400) + ->select(); + + $closed = 0; + foreach ($expired as $order) { + $affected = Db::name('fc_order') + ->where('id', (int)$order['id']) + ->where('status', 'pending') + ->update([ + 'status' => 'closed', + 'updatetime' => $now, + ]); + if ($affected) { + CouponService::release((string)$order['order_no']); + $closed++; + } + } + $output->writeln('==> 关闭超时订单:' . $closed . ' 笔'); + + /* 3. 同步转账中的提现单(最多 20 笔) */ + $synced = 0; + if (class_exists('\app\common\library\WxTransfer') && \app\common\library\WxTransfer::isReady()) { + $rows = Db::name('fc_withdraw')->where('status', 'paying')->limit(20)->select(); + foreach ($rows as $row) { + try { + \app\common\library\WxTransfer::sync((int)$row['id']); + $synced++; + } catch (\Throwable $e) { + // 单笔失败不影响其它 + } + } + } + $output->writeln('==> 同步转账状态:' . $synced . ' 笔'); + + $output->writeln('==> 完成'); + } +} diff --git a/application/admin/controller/Index.php b/application/admin/controller/Index.php index 8c55447..511b2e5 100755 --- a/application/admin/controller/Index.php +++ b/application/admin/controller/Index.php @@ -5,6 +5,7 @@ namespace app\admin\controller; use app\admin\model\AdminLog; use app\common\controller\Backend; use think\Config; +use think\Db; use think\Hook; use think\Session; use think\Validate; @@ -40,11 +41,29 @@ class Index extends Backend } } //左侧菜单 - list($menulist, $navlist, $fixedmenu, $referermenu) = $this->auth->getSidebar([ - 'dashboard' => 'hot', - 'addon' => ['new', 'red', 'badge'], - 'auth/rule' => __('Menu'), - ], $this->view->site['fixedpage']); + // 角标只展示需要处理的业务,不再使用 hot/new 这类装饰性角标 + $badges = []; + try { + $pendingReport = (int)Db::name('fc_report')->where('status', 'pending')->count(); + $pendingWithdraw = (int)Db::name('fc_withdraw')->where('status', 'auditing')->count(); + $pendingMerchant = (int)Db::name('fc_merchant')->where('audit_status', 0)->count(); + if ($pendingReport > 0) { + $badges['fc/report'] = [$pendingReport, 'orange']; + $badges['fc/content'] = [$pendingReport, 'orange']; + } + if ($pendingWithdraw > 0) { + $badges['fc/withdraw'] = [$pendingWithdraw, 'red']; + $badges['fc/trade'] = [$pendingWithdraw, 'red']; + } + if ($pendingMerchant > 0) { + $badges['fc/merchant'] = [$pendingMerchant, 'yellow']; + $badges['fc/user'] = [$pendingMerchant, 'yellow']; + } + } catch (\Exception $e) { + // 业务表缺失时不影响后台首页 + } + + list($menulist, $navlist, $fixedmenu, $referermenu) = $this->auth->getSidebar($badges, $this->view->site['fixedpage']); $action = $this->request->request('action'); if ($this->request->isPost()) { if ($action == 'refreshmenu') { diff --git a/application/admin/controller/auth/Rule.php b/application/admin/controller/auth/Rule.php index 4e30d3c..1694473 100755 --- a/application/admin/controller/auth/Rule.php +++ b/application/admin/controller/auth/Rule.php @@ -11,7 +11,7 @@ use think\Cache; * 规则管理 * * @icon fa fa-list - * @remark 规则通常对应一个控制器的方法,同时左侧的菜单栏数据也从规则中体现,通常建议通过控制台进行生成规则节点 + * @remark 左侧菜单与权限节点都在这里维护;修改后左侧导航自动刷新,删除菜单会连同其子级一起删除 */ class Rule extends Backend { @@ -57,8 +57,8 @@ class Rule extends Backend public function index() { if ($this->request->isAjax()) { - $list = $this->rulelist; - $total = count($this->rulelist); + $list = $this->assignSeq($this->filterRuleList($this->rulelist)); + $total = count($list); $result = array("total" => $total, "rows" => $list); return json($result); @@ -66,6 +66,116 @@ class Rule extends Backend return $this->view->fetch(); } + /** + * 按筛选条件过滤规则树 + * + * 支持:仅菜单 / 仅权限节点 / 全部、状态、关键词(标题或规则名)。 + * 命中节点的父级会一并保留,避免树形层级断裂。 + * + * @param array $list + * @return array + */ + protected function filterRuleList(array $list) + { + $type = (string)$this->request->request('rule_filter', 'menu'); + $status = (string)$this->request->request('rule_status', ''); + $keyword = trim((string)$this->request->request('rule_keyword', '')); + + $type = in_array($type, ['menu', 'node', 'all'], true) ? $type : 'all'; + $status = in_array($status, ['normal', 'hidden'], true) ? $status : ''; + if ($type === 'all' && $status === '' && $keyword === '') { + return $list; + } + + $byId = []; + foreach ($list as $row) { + $byId[(int)$row['id']] = $row; + } + + $keep = []; + foreach ($list as $row) { + $match = true; + if ($type === 'menu' && !$row['ismenu']) { + $match = false; + } + if ($type === 'node' && $row['ismenu']) { + $match = false; + } + if ($status !== '' && $row['status'] !== $status) { + $match = false; + } + if ($keyword !== '' && stripos($row['title'], $keyword) === false && stripos($row['name'], $keyword) === false) { + $match = false; + } + if (!$match) { + continue; + } + // 保留命中节点的所有父级 + $id = (int)$row['id']; + $guard = 0; + while ($id && isset($byId[$id]) && $guard++ < 20) { + $keep[$id] = true; + $id = (int)$byId[$id]['pid']; + } + } + + $result = []; + foreach ($list as $row) { + if (isset($keep[(int)$row['id']])) { + $result[] = $row; + } + } + return $result; + } + + /** + * 生成层级序号:1、1.1、1.2、2、2.1…… + * + * 树形列表默认会隐藏子行,若按行号显示序号会出现 1/7/28 这类跳号; + * 这里按父子层级编号,展开/收起都稳定可读。 + * + * @param array $list + * @return array + */ + protected function assignSeq(array $list) + { + $byId = []; + foreach ($list as $row) { + $byId[(int)$row['id']] = $row; + } + + $seqById = []; + $counters = []; + foreach ($list as &$row) { + $depth = 0; + $pid = (int)$row['pid']; + $guard = 0; + while ($pid && isset($byId[$pid]) && $guard++ < 20) { + $depth++; + $pid = (int)$byId[$pid]['pid']; + } + + $counters[$depth] = isset($counters[$depth]) ? $counters[$depth] + 1 : 1; + if ($depth === 0) { + $seq = (string)$counters[$depth]; + } else { + $parentSeq = isset($seqById[(int)$row['pid']]) ? $seqById[(int)$row['pid']] : ''; + $seq = ($parentSeq !== '' ? $parentSeq . '.' : '') . $counters[$depth]; + } + + // 进入新分支后,重置更深的层级计数 + for ($i = $depth + 1; $i <= 10; $i++) { + unset($counters[$i]); + } + + $row['seq'] = $seq; + $seqById[(int)$row['id']] = $seq; + } + unset($row); + + return $list; + } + /** * 添加 */ diff --git a/application/admin/controller/fc/Area.php b/application/admin/controller/fc/Area.php index f54c289..30fdad1 100644 --- a/application/admin/controller/fc/Area.php +++ b/application/admin/controller/fc/Area.php @@ -3,11 +3,12 @@ namespace app\admin\controller\fc; use app\common\controller\Backend; +use think\Db; /** - * 同城-服务区域 + * 同城-服务区域(省 / 市 / 区县平铺,区县为最终服务区域) * - * @icon fa fa-circle-o + * @icon fa fa-map-o */ class Area extends Backend { @@ -22,16 +23,81 @@ class Area extends Backend { parent::_initialize(); $this->model = new \app\admin\model\FcArea; - } - + /** + * 新增:保存前校验 + */ + public function add() + { + $this->assignAreaOptions(); + if ($this->request->isPost()) { + $this->validateArea($this->request->post('row/a'), 0); + } + return parent::add(); + } /** - * 默认生成的控制器所继承的父类中有index/add/edit/del/multi五个基础方法、destroy/restore/recyclebin三个回收站方法 - * 因此在当前控制器中可不用编写增删改查的代码,除非需要自己控制这部分逻辑 - * 需要将application/admin/library/traits/Backend.php中对应的方法复制到当前控制器,然后进行修改 + * 编辑:保存前校验 + * @param string $ids */ + public function edit($ids = null) + { + $this->assignAreaOptions(); + if ($this->request->isPost()) { + $this->validateArea($this->request->post('row/a'), (int)$this->request->param('ids')); + } + return parent::edit($ids); + } + /** 省 / 市 / 区县候选值,供表单级联下拉使用 */ + protected function assignAreaOptions() + { + $rows = Db::name('fc_area') + ->field('province,city,district') + ->order('province asc, city asc, district asc') + ->select(); + $this->view->assign('areaOptionsJson', json_encode($rows, JSON_UNESCAPED_UNICODE)); + } + /** + * 保存前校验:省 / 市 / 区县均必填,同一组合不可重复 + * @param array $params 表单数据 + * @param int $id 编辑时的区域ID + */ + protected function validateArea($params, $id = 0) + { + $province = isset($params['province']) ? trim((string)$params['province']) : ''; + $city = isset($params['city']) ? trim((string)$params['city']) : ''; + $district = isset($params['district']) ? trim((string)$params['district']) : ''; + + $values = ['省份' => $province, '城市' => $city, '区县' => $district]; + foreach ($values as $label => $value) { + if ($value === '') { + $this->error($label . '不能为空'); + } + if (mb_strlen($value) > 32) { + $this->error($label . '不能超过 32 个字符'); + } + } + + $duplicate = Db::name('fc_area') + ->where('province', $province) + ->where('city', $city) + ->where('district', $district) + ->where('id', '<>', $id) + ->find(); + if ($duplicate) { + $this->error('该省市区组合已存在'); + } + + foreach (['lat' => 90, 'lng' => 180] as $field => $limit) { + if (!isset($params[$field]) || $params[$field] === '' || $params[$field] === null) { + continue; + } + if (!is_numeric($params[$field]) || (float)$params[$field] < -$limit || (float)$params[$field] > $limit) { + $this->error($field === 'lat' ? '纬度格式不正确' : '经度格式不正确'); + } + } + } } diff --git a/application/admin/controller/fc/Dashboard.php b/application/admin/controller/fc/Dashboard.php index e27fb5a..2acfeba 100644 --- a/application/admin/controller/fc/Dashboard.php +++ b/application/admin/controller/fc/Dashboard.php @@ -89,6 +89,13 @@ class Dashboard extends Backend $membersMap[$row['day']] = (int)$row['num']; } + /* ---------- 今日较昨日的变化(用于首页涨跌标识) ---------- */ + $yesterday = date('Y-m-d', $todayStart - 86400); + $metrics['yesterdayPost'] = isset($postsMap[$yesterday]) ? $postsMap[$yesterday] : 0; + $metrics['yesterdayOrder'] = isset($ordersMap[$yesterday]) ? $ordersMap[$yesterday] : 0; + $metrics['postDelta'] = $metrics['todayPost'] - $metrics['yesterdayPost']; + $metrics['orderDelta'] = round($metrics['todayOrderAmount'] - $metrics['yesterdayOrder'], 2); + $trend = [ 'dates' => $days, 'posts' => [], @@ -115,6 +122,7 @@ class Dashboard extends Backend $this->view->assign('metrics', $metrics); $this->view->assign('trendRows', $trendRows); + $this->view->assign('updatedAt', date('Y-m-d H:i')); $this->assignconfig('trend', $trend); return $this->view->fetch(); diff --git a/application/admin/controller/fc/Financeflow.php b/application/admin/controller/fc/Financeflow.php index e49cc3e..061f925 100644 --- a/application/admin/controller/fc/Financeflow.php +++ b/application/admin/controller/fc/Financeflow.php @@ -3,6 +3,7 @@ namespace app\admin\controller\fc; use app\common\controller\Backend; +use think\Db; /** * 同城-资金流水 @@ -34,4 +35,132 @@ class Financeflow extends Backend */ + /** + * 导出资金流水(Excel) + * GET fc/financeflow/export?type=income|expense + */ + public function export() + { + $type = trim((string)$this->request->get('type', '')); + + $q = Db::name('fc_finance_flow'); + if ($type !== '') { + $q->where('type', $type); + } + $rows = $q->order('createtime desc')->limit(50000)->select(); + + $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('资金流水'); + $sheet->fromArray(['流水号', '类型', '场景', '金额(元)', '账户余额(元)', '关联ID', '备注', '时间'], null, 'A1'); + + $r = 2; + foreach ($rows as $row) { + $sheet->fromArray([ + (string)$row['flow_no'], + $row['type'] === 'income' ? '收入' : '支出', + (string)$row['scene'], + (float)$row['amount'], + (float)$row['balance'], + (int)$row['related_id'], + (string)$row['remark'], + date('Y-m-d H:i:s', (int)$row['createtime']), + ], null, 'A' . $r); + $r++; + } + + $this->downloadXlsx($spreadsheet, '资金流水_' . date('YmdHis')); + } + + /** + * 导出营收报表(Excel) + * GET fc/financeflow/exportReport?period=day|month|year + */ + public function exportReport() + { + $period = trim((string)$this->request->get('period', 'month')); + if (!in_array($period, ['day', 'month', 'year'], true)) { + $period = 'month'; + } + $fmtMap = ['day' => '%Y-%m-%d', 'month' => '%Y-%m', 'year' => '%Y']; + $fmt = $fmtMap[$period]; + + $post = $this->periodSum('post', $fmt); + $member = $this->periodSum('member', $fmt); + $recharge = $this->periodSum('recharge', $fmt); + $withdraw = $this->periodWithdraw($fmt); + + $keys = array_unique(array_merge(array_keys($post), array_keys($member), array_keys($recharge), array_keys($withdraw))); + sort($keys); + + $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('营收报表'); + $sheet->fromArray(['周期', '发布收入', '会员收入', '充值收入', '提现支出', '净收入'], null, 'A1'); + + $r = 2; + foreach ($keys as $k) { + $p = isset($post[$k]) ? (float)$post[$k] : 0; + $m = isset($member[$k]) ? (float)$member[$k] : 0; + $rc = isset($recharge[$k]) ? (float)$recharge[$k] : 0; + $w = isset($withdraw[$k]) ? (float)$withdraw[$k] : 0; + $sheet->fromArray([ + (string)$k, + round($p, 2), + round($m, 2), + round($rc, 2), + round($w, 2), + round($p + $m + $rc - $w, 2), + ], null, 'A' . $r); + $r++; + } + + $label = ['day' => '日', 'month' => '月', 'year' => '年'][$period]; + $this->downloadXlsx($spreadsheet, '营收报表_按' . $label . '_' . date('YmdHis')); + } + + /** 按周期聚合某类已支付订单 */ + protected function periodSum($type, $fmt) + { + $rows = Db::name('fc_order') + ->where('status', 'paid') + ->where('type', $type) + ->field("FROM_UNIXTIME(createtime, '{$fmt}') AS period, SUM(amount) AS total") + ->group('period') + ->select(); + $map = []; + foreach ($rows as $row) { + $map[$row['period']] = (float)$row['total']; + } + return $map; + } + + /** 按周期聚合已到账提现 */ + protected function periodWithdraw($fmt) + { + $rows = Db::name('fc_withdraw') + ->where('status', 'paid') + ->whereNotNull('paytime') + ->field("FROM_UNIXTIME(paytime, '{$fmt}') AS period, SUM(amount) AS total") + ->group('period') + ->select(); + $map = []; + foreach ($rows as $row) { + $map[$row['period']] = (float)$row['total']; + } + return $map; + } + + /** 输出 xlsx 下载 */ + protected function downloadXlsx($spreadsheet, $filename) + { + $filename = preg_replace('/[^\w\x{4e00}-\x{9fa5}\-]/u', '', $filename) . '.xlsx'; + header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + header('Content-Disposition: attachment;filename="' . $filename . '"'); + header('Cache-Control: max-age=0'); + + $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet); + $writer->save('php://output'); + exit; + } } diff --git a/application/admin/controller/fc/Order.php b/application/admin/controller/fc/Order.php index 3086b35..38a5848 100644 --- a/application/admin/controller/fc/Order.php +++ b/application/admin/controller/fc/Order.php @@ -40,37 +40,43 @@ class Order extends Backend public function refund() { $ids = $this->request->post('ids'); - $amount = (float)$this->request->post('amount'); + $amount = $this->request->post('amount'); $reason = $this->request->post('reason', ''); - if ($amount <= 0) { - $this->error('退款金额需大于 0'); - } + $ids = is_array($ids) ? $ids : explode(',', (string)$ids); $ids = array_filter($ids); if (!$ids) { $this->error('请选择要退款的订单'); } + $orders = Db::name('fc_order')->where('id', 'in', $ids)->select(); if (!$orders) { $this->error('订单不存在'); } + + // 金额留空 = 按每笔订单实付金额全额退款 + $amount = ($amount === '' || $amount === null || (float)$amount <= 0) ? null : (float)$amount; + + $ok = 0; + $errors = []; foreach ($orders as $order) { - Db::name('fc_order')->where('id', $order['id'])->update([ - 'status' => 'refunded', - 'refund_amount' => $amount, - 'refund_reason' => $reason, - 'updatetime' => time(), - ]); - Db::name('fc_message')->insert([ - 'user_id' => $order['user_id'], - 'type' => 'system', - 'title' => '订单退款', - 'content' => '您的订单「' . $order['order_no'] . '」已退款' . $amount . '元' . ($reason ? ',原因:' . $reason : ''), - 'isread' => 0, - 'createtime' => time(), - ]); + try { + \app\common\library\OrderRefund::refund((int)$order['id'], $amount, (string)$reason); + $ok++; + } catch (\Throwable $e) { + $errors[] = $order['order_no'] . ':' . $e->getMessage(); + } } - $this->success('退款成功'); + + if ($ok === 0) { + $this->error('退款失败:' . implode(';', $errors)); + } + + $msg = '退款成功 ' . $ok . ' 笔'; + if ($errors) { + $msg .= ',失败 ' . count($errors) . ' 笔:' . implode(';', $errors); + } + $this->success($msg); } /** diff --git a/application/admin/controller/fc/Setting.php b/application/admin/controller/fc/Setting.php index 31edb39..fdd8242 100644 --- a/application/admin/controller/fc/Setting.php +++ b/application/admin/controller/fc/Setting.php @@ -47,14 +47,20 @@ class Setting extends Backend if ($appName === '') { $this->error('小程序名称不能为空'); } + $logo = isset($params['logo']) ? trim((string)$params['logo']) : ''; - $this->setConfig([ + $values = [ 'base.appName' => ['value' => $appName, 'group_key' => 'base', 'remark' => '小程序名称'], - 'base.logo' => ['value' => isset($params['logo']) ? trim((string)$params['logo']) : '', 'group_key' => 'base', 'remark' => 'LOGO地址'], + 'base.logo' => ['value' => $logo, 'group_key' => 'base', 'remark' => 'LOGO地址'], 'base.servicePhone' => ['value' => isset($params['servicePhone']) ? trim((string)$params['servicePhone']) : '', 'group_key' => 'base', 'remark' => '客服电话'], 'base.aboutUs' => ['value' => isset($params['aboutUs']) ? trim((string)$params['aboutUs']) : '', 'group_key' => 'base', 'remark' => '关于我们'], 'base.shareTitle' => ['value' => isset($params['shareTitle']) ? trim((string)$params['shareTitle']) : '', 'group_key' => 'base', 'remark' => '分享标题'], - ]); + // 兼容旧 ui.* 键(用户端已优先读 base.*) + 'ui.siteName' => ['value' => $appName, 'group_key' => 'ui', 'remark' => '站点名称'], + 'ui.logo' => ['value' => $logo, 'group_key' => 'ui', 'remark' => '自定义LOGO'], + ]; + + $this->setConfig($values); $this->success('小程序设置已保存'); } diff --git a/application/admin/controller/fc/Userext.php b/application/admin/controller/fc/Userext.php index b802321..af277b0 100644 --- a/application/admin/controller/fc/Userext.php +++ b/application/admin/controller/fc/Userext.php @@ -67,6 +67,8 @@ class Userext extends Backend * 说明: * 余额存放在核心表 fa_user.money,本表(fc_user_ext)仅作业务扩展, * 因此这里按 user_id 关联 fa_user 进行加减。 + * 【注意】ids 必须是 fa_user.id(即本表的 user_id 字段), + * 不能传 fc_user_ext.id,否则会充错人(或报「未找到对应用户」)。 * 为可追溯,每次充值同时写入 fa_fc_finance_flow(资金流水)与 * fa_fc_message(站内通知),后台操作留痕由 Backend 自动记录。 */ @@ -89,52 +91,66 @@ class Userext extends Backend $remark = trim((string)$this->request->post('remark', '')); $now = time(); $done = 0; + $missing = []; - foreach ($userIds as $uid) { - $user = Db::name('user')->where('id', $uid)->find(); - if (!$user) { - continue; + Db::startTrans(); + try { + foreach ($userIds as $uid) { + // 加行锁,避免并发充值互相覆盖余额 + $user = Db::name('user')->where('id', $uid)->lock(true)->find(); + if (!$user) { + $missing[] = $uid; + continue; + } + + $balance = round((float)$user['money'] + $amount, 2); + Db::name('user')->where('id', $uid)->update([ + 'money' => $balance, + 'updatetime' => $now, + ]); + + // 资金流水(平台口径:余额充值属于收入) + Db::name('fc_finance_flow')->insert([ + 'flow_no' => 'R' . date('YmdHis') . mt_rand(100, 999) . $uid, + 'type' => 'income', + 'scene' => '余额充值', + 'amount' => $amount, + 'balance' => $balance, + 'related_id' => $uid, + 'remark' => $remark !== '' ? $remark : ('后台充值给用户 #' . $uid), + 'createtime' => $now, + ]); + + // 站内通知 + Db::name('fc_message')->insert([ + 'user_id' => $uid, + 'type' => 'system', + 'title' => '余额充值到账', + 'content' => '您的账户已充值 ¥' . number_format($amount, 2, '.', '') + . ',当前余额 ¥' . number_format($balance, 2, '.', '') + . '。' . ($remark !== '' ? '备注:' . $remark : ''), + 'isread' => 0, + 'readtime' => null, + 'createtime' => $now, + ]); + + $done++; } - - $balance = round((float)$user['money'] + $amount, 2); - Db::name('user')->where('id', $uid)->update([ - 'money' => $balance, - 'updatetime' => $now, - ]); - - // 资金流水(平台口径:余额充值属于收入) - Db::name('fc_finance_flow')->insert([ - 'flow_no' => 'R' . date('YmdHis') . mt_rand(100, 999) . $uid, - 'type' => 'income', - 'scene' => '余额充值', - 'amount' => $amount, - 'balance' => $balance, - 'related_id' => $uid, - 'remark' => $remark !== '' ? $remark : ('后台充值给用户 #' . $uid), - 'createtime' => $now, - ]); - - // 站内通知 - Db::name('fc_message')->insert([ - 'user_id' => $uid, - 'type' => 'system', - 'title' => '余额充值到账', - 'content' => '您的账户已充值 ¥' . number_format($amount, 2, '.', '') - . ',当前余额 ¥' . number_format($balance, 2, '.', '') - . '。' . ($remark !== '' ? '备注:' . $remark : ''), - 'isread' => 0, - 'readtime' => null, - 'createtime' => $now, - ]); - - $done++; + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + $this->error('充值失败:' . $e->getMessage()); } if (!$done) { - $this->error('未找到对应用户'); + $this->error('未找到对应用户:' . implode(',', $missing) . '(请确认传的是用户ID)'); } - $this->success('充值成功:' . $done . ' 个用户,每人 ¥' . number_format($amount, 2, '.', '')); + $msg = '充值成功:' . $done . ' 个用户,每人 ¥' . number_format($amount, 2, '.', ''); + if ($missing) { + $msg .= ';已跳过不存在的用户:' . implode(',', $missing); + } + $this->success($msg); } /** diff --git a/application/admin/controller/fc/Withdraw.php b/application/admin/controller/fc/Withdraw.php index 048752f..672111f 100644 --- a/application/admin/controller/fc/Withdraw.php +++ b/application/admin/controller/fc/Withdraw.php @@ -57,42 +57,81 @@ class Withdraw extends Backend if (!$withdraws) { $this->error('提现记录不存在'); } - foreach ($withdraws as $withdraw) { - if ($op === 'pay') { - Db::name('fc_withdraw')->where('id', $withdraw['id'])->update([ - 'status' => 'paid', - 'paytime' => time(), - 'updatetime' => time(), - ]); - Db::name('fc_finance_flow')->insert([ - 'flow_no' => 'WD' . date('YmdHis') . $withdraw['id'], - 'type' => 'expense', - 'scene' => '佣金提现', - 'amount' => $withdraw['amount'], - 'balance' => 0, - 'related_id' => $withdraw['id'], - 'remark' => '提现打款', - 'createtime' => time(), - ]); - } else { - Db::name('fc_withdraw')->where('id', $withdraw['id'])->update([ - 'status' => 'rejected', - 'reason' => $reason, - 'updatetime' => time(), - ]); + + $done = 0; + $pending = 0; + $failed = 0; + $errors = []; + + if ($op === 'reject') { + try { + Db::transaction(function () use ($withdraws, $reason, &$done) { + foreach ($withdraws as $withdraw) { + // 行锁 + 状态校验:已处理的记录不会被二次驳回 + $locked = Db::name('fc_withdraw')->where('id', $withdraw['id'])->lock(true)->find(); + if (!$locked || (string)$locked['status'] !== 'auditing') { + continue; + } + $done++; + + Db::name('fc_withdraw')->where('id', $locked['id'])->update([ + 'status' => 'rejected', + 'reason' => $reason, + 'updatetime' => time(), + ]); + Db::name('fc_message')->insert([ + 'user_id' => $locked['user_id'], + 'type' => 'system', + 'title' => '提现驳回', + 'content' => '您的提现申请「' . $locked['withdraw_no'] . '」被驳回' . ($reason ? ',原因:' . $reason : ''), + 'isread' => 0, + 'createtime' => time(), + ]); + } + }); + } catch (\Throwable $e) { + $this->error('操作失败:' . $e->getMessage()); } - Db::name('fc_message')->insert([ - 'user_id' => $withdraw['user_id'], - 'type' => 'system', - 'title' => $op === 'pay' ? '提现成功' : '提现驳回', - 'content' => $op === 'pay' - ? '您的提现申请「' . $withdraw['withdraw_no'] . '」已打款,金额:' . $withdraw['amount'] . '元' - : '您的提现申请「' . $withdraw['withdraw_no'] . '」被驳回' . ($reason ? ',原因:' . $reason : ''), - 'isread' => 0, - 'createtime' => time(), - ]); + + if ($done === 0) { + $this->error('选中的提现记录均已处理,请刷新后重试'); + } + $this->success('驳回成功(' . $done . ' 条)'); } - $this->success($op === 'pay' ? '打款成功' : '驳回成功'); + + // 通过 → 逐笔发起微信商家转账(外部调用不能放在事务里) + foreach ($withdraws as $withdraw) { + if ((string)$withdraw['status'] !== 'auditing') { + continue; + } + try { + $res = \app\common\library\WxTransfer::approve((int)$withdraw['id']); + $done++; + if ($res['withdrawState'] === 'paying') { + $pending++; + } elseif ($res['withdrawState'] === 'failed') { + $failed++; + } + } catch (\Throwable $e) { + $errors[] = $withdraw['withdraw_no'] . ':' . $e->getMessage(); + } + } + + if ($done === 0) { + $this->error('转账失败:' . implode(';', $errors)); + } + + $msg = '已发起转账 ' . $done . ' 笔'; + if ($pending > 0) { + $msg .= '(其中 ' . $pending . ' 笔等待用户在微信确认收款)'; + } + if ($failed > 0) { + $msg .= ',' . $failed . ' 笔转账失败'; + } + if ($errors) { + $msg .= ';失败 ' . count($errors) . ' 笔:' . implode(';', $errors); + } + $this->success($msg); } diff --git a/application/admin/controller/general/Config.php b/application/admin/controller/general/Config.php index 5dac56c..9fdf5ba 100755 --- a/application/admin/controller/general/Config.php +++ b/application/admin/controller/general/Config.php @@ -4,6 +4,7 @@ namespace app\admin\controller\general; use app\common\controller\Backend; use app\common\library\Email; +use app\common\library\FcConfig; use app\common\model\Config as ConfigModel; use think\Cache; use think\Db; @@ -75,11 +76,30 @@ class Config extends Backend } $siteList[$v['group']]['list'][] = $value; } + // 把小程序 / 发布 / 分销三组业务配置并入系统配置页(值真源仍是 fa_fc_config) + $fcGroups = FcConfig::groups(); + $merged = []; + foreach ($siteList as $k => $v) { + $merged[$k] = $v; + if ($k === 'basic') { + foreach ($fcGroups as $gk => $gv) { + $merged[$gk] = $gv; + } + } + } + if (!isset($merged['fc_miniapp'])) { + foreach ($fcGroups as $gk => $gv) { + $merged[$gk] = $gv; + } + } + $siteList = $merged; + $index = 0; foreach ($siteList as $k => &$v) { $v['active'] = !$index ? true : false; $index++; } + unset($v); $this->view->assign('siteList', $siteList); $this->view->assign('typeList', ConfigModel::getTypeList()); $this->view->assign('ruleList', ConfigModel::getRegexList()); @@ -138,6 +158,13 @@ class Config extends Backend $this->token(); $row = $this->request->post("row/a", [], 'trim'); if ($row) { + // 小程序 / 发布 / 分销:写入 fc_config(系统配置页的业务分组) + try { + FcConfig::save($row); + } catch (\Exception $e) { + $this->error($e->getMessage()); + } + $configList = []; foreach ($this->model->all() as $v) { if (isset($row[$v['name']])) { @@ -152,14 +179,18 @@ class Config extends Backend } } try { - $this->model->allowField(true)->saveAll($configList); + if ($configList) { + $this->model->allowField(true)->saveAll($configList); + } } catch (Exception $e) { $this->error($e->getMessage()); } - try { - ConfigModel::refreshFile(); - } catch (Exception $e) { - $this->error($e->getMessage()); + if ($configList) { + try { + ConfigModel::refreshFile(); + } catch (Exception $e) { + $this->error($e->getMessage()); + } } $this->success(); } diff --git a/application/admin/lang/zh-cn/fc/area.php b/application/admin/lang/zh-cn/fc/area.php index 63bde8a..6315444 100644 --- a/application/admin/lang/zh-cn/fc/area.php +++ b/application/admin/lang/zh-cn/fc/area.php @@ -2,7 +2,12 @@ return [ 'Id' => 'ID', - 'Name' => '区域名称', + 'Province' => '省份', + 'City' => '城市', + 'District' => '区县', + 'FullName' => '完整区域', + 'Lat' => '中心纬度', + 'Lng' => '中心经度', 'Sort' => '排序', 'Enabled' => '是否启用', 'Createtime' => '创建时间', diff --git a/application/admin/lang/zh-cn/fc/dashboard.php b/application/admin/lang/zh-cn/fc/dashboard.php index 37f2984..8e6a5a0 100644 --- a/application/admin/lang/zh-cn/fc/dashboard.php +++ b/application/admin/lang/zh-cn/fc/dashboard.php @@ -4,6 +4,18 @@ return [ '核心看板' => '核心看板', '全部指标由后台直接统计;订单额仅统计「已支付」订单,佣金不含已驳回部分,提现为「待审核」笔数。' => '全部指标由后台直接统计;订单额仅统计「已支付」订单,佣金不含已驳回部分,提现为「待审核」笔数。', + '业务总览' => '业务总览', + '数据更新于' => '数据更新于', + '订单额仅统计已支付订单' => '订单额仅统计已支付订单', + '内容管理' => '内容管理', + '提现审核' => '提现审核', + '刷新数据' => '刷新数据', + '今日核心指标' => '今日核心指标', + '较昨日' => '较昨日', + '与昨日持平' => '与昨日持平', + '去审核' => '去审核', + '去处理' => '去处理', + '暂无待审核' => '暂无待审核', '今日发布量' => '今日发布量', '累计发布量' => '累计发布量', '待审核信息数' => '待审核信息数', @@ -12,15 +24,27 @@ return [ '会员数' => '会员数', '商家数' => '商家数', '注册用户数' => '注册用户数', + '平台累计' => '平台累计', + '分销佣金' => '分销佣金', '待结算佣金' => '待结算佣金', '已结算佣金' => '已结算佣金', '待审核提现数' => '待审核提现数', '近 14 天趋势' => '近 14 天趋势', - '(发布量按发布时间统计;订单额按支付时间统计;新增会员为新增注册用户)' - => '(发布量按发布时间统计;订单额按支付时间统计;新增会员为新增注册用户)', + '近 14 天' => '近 14 天', + '趋势图' => '趋势图', + '近 14 天合计' => '近 14 天合计', + '切换趋势指标' => '切换趋势指标', + '发布量按发布时间统计,订单额按支付时间统计' + => '发布量按发布时间统计,订单额按支付时间统计', + '近 14 天明细' => '近 14 天明细', + '展开查看每日数据' => '展开查看每日数据', + '收起明细' => '收起明细', + '近 14 天每日发布量、订单额与新增注册用户' + => '近 14 天每日发布量、订单额与新增注册用户', '日期' => '日期', '发布量' => '发布量', '订单额' => '订单额', '订单额(元)' => '订单额(元)', + '新增用户' => '新增用户', '新增注册用户' => '新增注册用户', ]; diff --git a/application/admin/model/FcArea.php b/application/admin/model/FcArea.php index 6be3c9e..8db2bfa 100644 --- a/application/admin/model/FcArea.php +++ b/application/admin/model/FcArea.php @@ -4,17 +4,14 @@ namespace app\admin\model; use think\Model; - +/** + * 同城-服务区域(省 / 市 / 区县平铺,区县为最终服务区域) + */ class FcArea extends Model { - - - - - // 表名 protected $name = 'fc_area'; - + // 自动写入时间戳字段 protected $autoWriteTimestamp = 'integer'; @@ -25,9 +22,17 @@ class FcArea extends Model // 追加属性 protected $append = [ - + 'full_name', ]; - + + /** 完整区域:省 / 市 / 区县 */ + public function getFullNameAttr($value, $data) + { + $province = isset($data['province']) ? (string)$data['province'] : ''; + $city = isset($data['city']) ? (string)$data['city'] : ''; + $district = isset($data['district']) ? (string)$data['district'] : ''; + return $province . ' / ' . $city . ' / ' . $district; + } protected static function init() { @@ -38,13 +43,4 @@ class FcArea extends Model } }); } - - - - - - - - - } diff --git a/application/admin/model/FcOrder.php b/application/admin/model/FcOrder.php index 5e738e4..bfa980b 100644 --- a/application/admin/model/FcOrder.php +++ b/application/admin/model/FcOrder.php @@ -40,10 +40,11 @@ class FcOrder extends Model public function getStatusList() { return [ - 'pending' => __('待支付'), - 'paid' => __('已支付'), - 'refunded' => __('已退款'), - 'closed' => __('已关闭'), + 'pending' => __('待支付'), + 'paid' => __('已支付'), + 'refunding' => __('退款中'), + 'refunded' => __('已退款'), + 'closed' => __('已关闭'), ]; } diff --git a/application/admin/model/FcPost.php b/application/admin/model/FcPost.php index f42616a..69efd04 100644 --- a/application/admin/model/FcPost.php +++ b/application/admin/model/FcPost.php @@ -39,6 +39,21 @@ class FcPost extends Model { return FcRelation::categoryName(isset($data['category_id']) ? $data['category_id'] : 0); } + + protected static function init() + { + $syncArea = function ($row) { + $name = isset($row['area']) ? trim((string)$row['area']) : ''; + if ($name === '') { + $row['district_id'] = 0; + return; + } + $area = \think\Db::name('fc_area')->where('district', $name)->find(); + $row['district_id'] = $area ? (int)$area['id'] : 0; + }; + self::beforeInsert($syncArea); + self::beforeUpdate($syncArea); + } diff --git a/application/admin/model/FcWithdraw.php b/application/admin/model/FcWithdraw.php index bd9b2ad..5dc37c2 100644 --- a/application/admin/model/FcWithdraw.php +++ b/application/admin/model/FcWithdraw.php @@ -41,7 +41,9 @@ class FcWithdraw extends Model { return [ 'auditing' => __('待审核'), - 'paid' => __('已打款'), + 'paying' => __('转账中'), + 'paid' => __('已到账'), + 'failed' => __('转账失败'), 'rejected' => __('已驳回'), ]; } diff --git a/application/admin/view/auth/rule/add.html b/application/admin/view/auth/rule/add.html index 33fd459..9c4b8ca 100755 --- a/application/admin/view/auth/rule/add.html +++ b/application/admin/view/auth/rule/add.html @@ -4,18 +4,21 @@
{:build_radios('row[ismenu]', ['1'=>__('Yes'), '0'=>__('No')])} +

开启后显示在左侧菜单;关闭则只作为权限节点。

{:build_select('row[pid]', $ruledata, null, ['class'=>'form-control', 'required'=>''])} +

菜单分组可留空或选择上级菜单;权限节点必须选择所属菜单。

+

对应控制器/方法名,如 fc/post;创建后请勿随意修改,否则权限会失效。

@@ -44,12 +47,14 @@
+

高级用法,可留空。

{:build_radios('row[menutype]', $menutypeList)} +

一般保持“选项卡”即可;外部链接可选“链接”。

@@ -68,12 +73,14 @@
+

数字越大越靠前,建议按 10 递增。

{:build_radios('row[status]', ['normal'=>__('Normal'), 'hidden'=>__('Hidden')])} +

隐藏后不在左侧菜单显示,权限规则仍然保留。

{:build_select('row[pid]', $ruledata, $row['pid'], ['class'=>'form-control', 'required'=>''])} +

菜单分组可留空或选择上级菜单;权限节点必须选择所属菜单。

+

对应控制器/方法名,如 fc/post;创建后请勿随意修改,否则权限会失效。

@@ -44,12 +47,14 @@
+

高级用法,可留空。

{:build_radios('row[menutype]', $menutypeList, $row['menutype'])} +

一般保持“选项卡”即可;外部链接可选“链接”。

@@ -68,12 +73,14 @@
+

数字越大越靠前,建议按 10 递增。

{:build_radios('row[status]', ['normal'=>__('Normal'), 'hidden'=>__('Hidden')], $row['status'])} +

隐藏后不在左侧菜单显示,权限规则仍然保留。

diff --git a/application/admin/view/fc/financeflow/index.html b/application/admin/view/fc/financeflow/index.html index c9e55c4..c4910f0 100644 --- a/application/admin/view/fc/financeflow/index.html +++ b/application/admin/view/fc/financeflow/index.html @@ -10,6 +10,13 @@ {:__('Add')} {:__('Edit')} {:__('Delete')} + + 导出流水 + 导出营收 diff --git a/application/api/controller/AdminComment.php b/application/api/controller/AdminComment.php index a0907eb..e327a91 100644 --- a/application/api/controller/AdminComment.php +++ b/application/api/controller/AdminComment.php @@ -13,6 +13,13 @@ use think\Db; */ class AdminComment extends V1 { + /** 管理端接口必须校验管理员身份(V1 默认关闭了 URI 权限校验) */ + public function _initialize() + { + parent::_initialize(); + $this->adminOnly(); + } + /** * AC1 评价列表 * GET /api/v1/admin/comments 入参 {page,size} diff --git a/application/api/controller/AdminDashboard.php b/application/api/controller/AdminDashboard.php index 733313f..11e968f 100644 --- a/application/api/controller/AdminDashboard.php +++ b/application/api/controller/AdminDashboard.php @@ -12,6 +12,13 @@ use think\Db; */ class AdminDashboard extends V1 { + /** 管理端接口必须校验管理员身份(V1 默认关闭了 URI 权限校验) */ + public function _initialize() + { + parent::_initialize(); + $this->adminOnly(); + } + /** * AD1 核心指标 * GET /api/v1/admin/dashboard/overview diff --git a/application/api/controller/AdminDist.php b/application/api/controller/AdminDist.php index 2e5ef6a..6aeb342 100644 --- a/application/api/controller/AdminDist.php +++ b/application/api/controller/AdminDist.php @@ -14,6 +14,13 @@ use think\Db; */ class AdminDist extends V1 { + /** 管理端接口必须校验管理员身份(V1 默认关闭了 URI 权限校验) */ + public function _initialize() + { + parent::_initialize(); + $this->adminOnly(); + } + /** * AD1 分销设置读取 * GET /api/v1/admin/dist/setting → {enabled,level,level1Rate,level2Rate,settleCycle,minWithdraw} @@ -68,16 +75,38 @@ class AdminDist extends V1 ->order('w.createtime desc') ->select(); + // 兜底同步:转账中的单据主动查一次微信(回调丢失/本地无 HTTPS 回调时) + $synced = 0; + foreach ($rows as $row) { + if ((string)$row['status'] === 'paying' && $synced < 5) { + $synced++; + try { + \app\common\library\WxTransfer::sync((int)$row['id']); + } catch (\Throwable $e) { + // 同步失败不影响列表展示 + } + } + } + if ($synced > 0) { + $rows = Db::name('fc_withdraw')->alias('w') + ->join('user u', 'u.id = w.user_id', 'LEFT') + ->field('w.*, u.nickname AS nickname') + ->order('w.createtime desc') + ->select(); + } + $list = []; foreach ($rows as $row) { $list[] = [ - 'id' => (int)$row['id'], - 'withdrawNo' => (string)$row['withdraw_no'], - 'user' => (string)$row['nickname'], - 'amount' => (float)$row['amount'], - 'status' => (string)$row['status'], - 'channel' => (string)$row['channel'], - 'createdAt' => $this->iso($row['createtime']), + 'id' => (int)$row['id'], + 'withdrawNo' => (string)$row['withdraw_no'], + 'user' => (string)$row['nickname'], + 'amount' => (float)$row['amount'], + 'status' => (string)$row['status'], + 'channel' => (string)$row['channel'], + 'transferStatus' => (string)(isset($row['transfer_status']) ? $row['transfer_status'] : ''), + 'failReason' => (string)(isset($row['fail_reason']) ? $row['fail_reason'] : ''), + 'createdAt' => $this->iso($row['createtime']), ]; } @@ -103,27 +132,72 @@ class AdminDist extends V1 if (!$withdraw) { $this->fail('提现记录不存在', 404); } - - if ($op === 'reject') { - $status = 'rejected'; - Db::name('fc_withdraw')->where('id', $id)->update([ - 'status' => $status, - 'reason' => $reason, - ]); - $this->notify($withdraw['user_id'], 'system', '提现被驳回', '您的提现申请被驳回:' . $reason); - } else { - $status = 'paid'; - Db::name('fc_withdraw')->where('id', $id)->update([ - 'status' => $status, - 'paytime' => time(), - ]); - $this->flow('expense', '佣金提现', $withdraw['amount'], 0, $id); - $this->notify($withdraw['user_id'], 'system', '提现已打款', '您的提现申请已打款,请注意查收。'); + if ((string)$withdraw['status'] !== 'auditing') { + $this->fail('该提现记录已处理(当前状态:' . $withdraw['status'] . '),请勿重复操作'); } - $this->adminLog('处理提现 #' . $id . ' 结果' . $status); + if ($op === 'reject') { + try { + Db::transaction(function () use ($id, $reason) { + $locked = Db::name('fc_withdraw')->where('id', $id)->lock(true)->find(); + if (!$locked || (string)$locked['status'] !== 'auditing') { + throw new \RuntimeException('该提现记录已处理,请刷新后重试'); + } + Db::name('fc_withdraw')->where('id', $id)->update([ + 'status' => 'rejected', + 'reason' => $reason, + 'updatetime' => time(), + ]); + $this->notify($locked['user_id'], 'system', '提现被驳回', '您的提现申请被驳回:' . $reason); + }); + } catch (\Throwable $e) { + $this->fail($e->getMessage() ?: '操作失败,请重试'); + } - $this->ok(['id' => (int)$id, 'status' => $status]); + $this->adminLog('驳回提现 #' . $id); + $this->ok(['id' => (int)$id, 'status' => 'rejected']); + } + + // 审核通过 → 自动发起微信商家转账(用户需在微信确认收款) + try { + $res = \app\common\library\WxTransfer::approve($id); + } catch (\Throwable $e) { + $this->fail('转账失败:' . $e->getMessage()); + } + + $this->adminLog('提现转账 #' . $id . ' 结果' . $res['state']); + + $message = $res['withdrawState'] === 'paid' + ? '转账成功,已到账' + : ($res['withdrawState'] === 'failed' ? '转账失败,请查看失败原因' : '已提交转账,等待用户确认收款'); + + $this->ok([ + 'id' => (int)$id, + 'status' => $res['withdrawState'], + 'transferState' => $res['state'], + ], $message); + } + + /** + * 撤销「转账中」的提现单(用户长期不确认收款时使用) + * POST /api/v1/admin/dist/withdraw/:id/cancel + */ + public function cancelWithdraw() + { + $id = (int)$this->request->param('id', 0); + + try { + $state = \app\common\library\WxTransfer::cancel($id); + } catch (\Throwable $e) { + $this->fail('撤销失败:' . $e->getMessage()); + } + + $this->adminLog('撤销提现转账 #' . $id); + $this->ok([ + 'id' => $id, + 'status' => (string)Db::name('fc_withdraw')->where('id', $id)->value('status'), + 'transferState' => $state, + ]); } /** 组装分销设置结构 */ diff --git a/application/api/controller/AdminFinance.php b/application/api/controller/AdminFinance.php index 3c39dec..413dafc 100644 --- a/application/api/controller/AdminFinance.php +++ b/application/api/controller/AdminFinance.php @@ -12,6 +12,13 @@ use think\Db; */ class AdminFinance extends V1 { + /** 管理端接口必须校验管理员身份(V1 默认关闭了 URI 权限校验) */ + public function _initialize() + { + parent::_initialize(); + $this->adminOnly(); + } + /** * AF1 资金流水 * GET /api/v1/admin/finance/flows 入参 {type?,page,size} diff --git a/application/api/controller/AdminMember.php b/application/api/controller/AdminMember.php index 4116f67..3daf99c 100644 --- a/application/api/controller/AdminMember.php +++ b/application/api/controller/AdminMember.php @@ -12,6 +12,13 @@ use think\Db; */ class AdminMember extends V1 { + /** 管理端接口必须校验管理员身份(V1 默认关闭了 URI 权限校验) */ + public function _initialize() + { + parent::_initialize(); + $this->adminOnly(); + } + /** * AMB1 用户列表 * GET /api/v1/admin/users 入参 {page,size} diff --git a/application/api/controller/AdminMerchant.php b/application/api/controller/AdminMerchant.php index 3bceb71..3cd3d87 100644 --- a/application/api/controller/AdminMerchant.php +++ b/application/api/controller/AdminMerchant.php @@ -12,6 +12,13 @@ use think\Db; */ class AdminMerchant extends V1 { + /** 管理端接口必须校验管理员身份(V1 默认关闭了 URI 权限校验) */ + public function _initialize() + { + parent::_initialize(); + $this->adminOnly(); + } + /** * AM1 商家列表 * GET /api/v1/admin/merchants 入参 {status?,keyword?,page,size} diff --git a/application/api/controller/AdminOrder.php b/application/api/controller/AdminOrder.php index 5dfb505..a9320a4 100644 --- a/application/api/controller/AdminOrder.php +++ b/application/api/controller/AdminOrder.php @@ -13,6 +13,13 @@ use think\Db; */ class AdminOrder extends V1 { + /** 管理端接口必须校验管理员身份(V1 默认关闭了 URI 权限校验) */ + public function _initialize() + { + parent::_initialize(); + $this->adminOnly(); + } + /** * AO1 订单列表 * GET /api/v1/admin/orders 入参 {type?,page,size} @@ -63,28 +70,24 @@ class AdminOrder extends V1 public function refund() { $id = (int)$this->request->param('id', 0); - $amount = round((float)$this->input('amount', 0), 2); + $amount = $this->input('amount', null); $reason = trim((string)$this->input('reason', '')); + $amount = ($amount === null || $amount === '') ? null : (float)$amount; - if ($amount <= 0) { - $this->fail('退款金额需大于 0'); + try { + $res = \app\common\library\OrderRefund::refund($id, $amount, $reason); + } catch (\Throwable $e) { + $this->fail($e->getMessage()); } - $order = Db::name('fc_order')->where('id', $id)->find(); - if (!$order) { - $this->fail('订单不存在', 404); - } + $this->adminLog('订单退款 #' . $id . ' 金额' . $res['amount'] . ' 退款单号' . $res['refundNo']); - Db::name('fc_order')->where('id', $id)->update([ - 'status' => 'refunded', - 'refund_amount' => $amount, - 'refund_reason' => $reason, + $this->ok([ + 'id' => $id, + 'refunded' => true, + 'refundNo' => $res['refundNo'], + 'amount' => $res['amount'], ]); - - $this->notify($order['user_id'], 'system', '订单已退款', '您的订单「' . $order['title'] . '」已退款 ' . $amount . ' 元。'); - $this->adminLog('订单退款 #' . $id . ' 金额' . $amount); - - $this->ok(['id' => (int)$id, 'refunded' => true]); } /** @@ -104,15 +107,44 @@ class AdminOrder extends V1 $this->fail('订单不存在', 404); } - if ($order['status'] !== 'paid') { - Db::name('fc_order')->where('id', $order['id'])->update([ - 'status' => 'paid', - 'paytime' => time(), - ]); - $this->flow('income', '订单补单', $order['amount'], 0, $order['id']); - $this->adminLog('订单补单 #' . $orderNo); + if ((string)$order['status'] === 'paid') { + $this->ok(['orderNo' => (string)$orderNo, 'supplemented' => false], '订单已支付,无需补单'); + } + if ((string)$order['status'] !== 'pending') { + $this->fail('订单状态不支持补单(当前:' . $order['status'] . ')'); } - $this->ok(['orderNo' => (string)$orderNo, 'supplemented' => true]); + $now = time(); + $handled = false; + try { + Db::transaction(function () use ($order, $now, &$handled) { + $locked = Db::name('fc_order')->where('id', (int)$order['id'])->lock(true)->find(); + if (!$locked || (string)$locked['status'] !== 'pending') { + return; + } + + $affected = Db::name('fc_order') + ->where('id', (int)$order['id']) + ->where('status', 'pending') + ->update(['status' => 'paid', 'paytime' => $now, 'updatetime' => $now]); + if (!$affected) { + return; + } + + $locked['status'] = 'paid'; + $locked['paytime'] = $now; + $handled = true; + + // 补单必须真正发货:会员发放 / 余额入账 / 付费查看解锁,不能只改状态 + \app\common\library\OrderFulfill::handle($locked); + }); + } catch (\Throwable $e) { + \think\Log::record('补单失败:订单 ' . $orderNo . ',' . $e->getMessage(), 'error'); + $this->fail('补单失败:' . $e->getMessage()); + } + + $this->adminLog('订单补单 #' . $orderNo . ($handled ? '' : '(幂等命中)')); + + $this->ok(['orderNo' => (string)$orderNo, 'supplemented' => $handled]); } } diff --git a/application/api/controller/AdminPost.php b/application/api/controller/AdminPost.php index 32d6b1d..de81eee 100644 --- a/application/api/controller/AdminPost.php +++ b/application/api/controller/AdminPost.php @@ -13,6 +13,13 @@ use think\Db; */ class AdminPost extends V1 { + /** 管理端接口必须校验管理员身份(V1 默认关闭了 URI 权限校验) */ + public function _initialize() + { + parent::_initialize(); + $this->adminOnly(); + } + /** * AP1 待审核信息列表 * GET /api/v1/admin/posts/pending 入参 {page,size} @@ -91,6 +98,18 @@ class AdminPost extends V1 $this->adminLog('审核信息 #' . $id . ' 结果' . $auditStatus); + // 订阅消息:审核结果(模板未配置/用户未授权时静默跳过) + \app\common\library\WxSubscribe::send( + (int)$post['user_id'], + 'audit', + [ + 'thing1' => ['value' => mb_substr((string)$post['title'], 0, 20)], + 'phrase2' => ['value' => $auditStatus === 1 ? '审核通过' : '审核未通过'], + 'thing3' => ['value' => $auditStatus === 1 ? '信息已公开展示' : mb_substr((string)$reason, 0, 20)], + ], + 'pages/detail/detail?id=' . $id + ); + $this->ok(['id' => (int)$id, 'auditStatus' => (int)$auditStatus]); } diff --git a/application/api/controller/AdminRbac.php b/application/api/controller/AdminRbac.php index c7070dd..f47e4fe 100644 --- a/application/api/controller/AdminRbac.php +++ b/application/api/controller/AdminRbac.php @@ -17,6 +17,13 @@ use think\Db; */ class AdminRbac extends V1 { + /** 管理端接口必须校验管理员身份(V1 默认关闭了 URI 权限校验) */ + public function _initialize() + { + parent::_initialize(); + $this->adminOnly(); + } + /** * AR1 角色列表 * GET /api/v1/admin/roles → 数组 [{id,name,permissions,remark}] diff --git a/application/api/controller/AdminSetting.php b/application/api/controller/AdminSetting.php index dce7551..96a4e7c 100644 --- a/application/api/controller/AdminSetting.php +++ b/application/api/controller/AdminSetting.php @@ -18,6 +18,13 @@ use think\Db; */ class AdminSetting extends V1 { + /** 管理端接口必须校验管理员身份(V1 默认关闭了 URI 权限校验) */ + public function _initialize() + { + parent::_initialize(); + $this->adminOnly(); + } + /** * AS1 基础配置 * GET → {appName,logo,servicePhone,aboutUs,shareTitle} @@ -30,11 +37,15 @@ class AdminSetting extends V1 if ($appName === '') { $this->fail('小程序名称不能为空'); } + $logo = (string)$this->input('logo', $this->cfg('base.logo', '')); $this->setCfg('base.appName', $appName); - $this->setCfg('base.logo', (string)$this->input('logo', $this->cfg('base.logo', ''))); + $this->setCfg('base.logo', $logo); $this->setCfg('base.servicePhone', (string)$this->input('servicePhone', $this->cfg('base.servicePhone', '400-000-0000'))); $this->setCfg('base.aboutUs', (string)$this->input('aboutUs', $this->cfg('base.aboutUs', ''))); $this->setCfg('base.shareTitle', (string)$this->input('shareTitle', $this->cfg('base.shareTitle', ''))); + // 兼容旧 ui.* 键,保持名称/LOGO 一致 + $this->setCfg('ui.siteName', $appName); + $this->setCfg('ui.logo', $logo); $this->adminLog('修改基础配置'); } @@ -116,20 +127,26 @@ class AdminSetting extends V1 } /** - * AS4 区域列表 - * GET /api/v1/admin/setting/areas → 数组 [{id,name,sort,enabled}] + * AS4 服务区域列表(平铺:每条记录自带省 / 市 / 区县) + * GET /api/v1/admin/setting/areas → [{id,province,city,district,fullName,lat,lng,sort,enabled}] + * 规则:按 province asc, city asc, sort asc, id asc;返回全量(含停用)。 */ public function areas() { - $rows = Db::name('fc_area')->order('sort asc')->select(); + $rows = Db::name('fc_area')->order('province asc, city asc, sort asc, id asc')->select(); $list = []; foreach ($rows as $row) { $list[] = [ - 'id' => (int)$row['id'], - 'name' => (string)$row['name'], - 'sort' => (int)$row['sort'], - 'enabled' => (bool)$row['enabled'], + 'id' => (int)$row['id'], + 'province' => (string)$row['province'], + 'city' => (string)$row['city'], + 'district' => (string)$row['district'], + 'fullName' => $row['province'] . ' / ' . $row['city'] . ' / ' . $row['district'], + 'lat' => $row['lat'] !== null ? (float)$row['lat'] : null, + 'lng' => $row['lng'] !== null ? (float)$row['lng'] : null, + 'sort' => (int)$row['sort'], + 'enabled' => (bool)$row['enabled'], ]; } @@ -137,35 +154,82 @@ class AdminSetting extends V1 } /** - * AS5 保存区域 - * POST /api/v1/admin/setting/area 入参 {id?,name,sort,enabled} → {id,name} + * AS5 保存服务区域 + * POST /api/v1/admin/setting/area 入参 {id?,province,city,district,sort,enabled,lat?,lng?} → {id,fullName} + * 规则:省 / 市 / 区县均必填;同一「省+市+区县」不允许重复。 */ public function saveArea() { $id = (int)$this->input('id', 0); - $name = trim((string)$this->input('name', '')); + $province = trim((string)$this->input('province', '')); + $city = trim((string)$this->input('city', '')); + $district = trim((string)$this->input('district', '')); $sort = (int)$this->input('sort', 0); $enabled = (bool)$this->input('enabled', true); + $lat = $this->input('lat', null); + $lng = $this->input('lng', null); - if ($name === '') { - $this->fail('区域名称不能为空'); + $values = ['省份' => $province, '城市' => $city, '区县' => $district]; + foreach ($values as $label => $value) { + if ($value === '') { + $this->fail($label . '不能为空'); + } + if (mb_strlen($value) > 32) { + $this->fail($label . '不能超过 32 个字符'); + } } + // 同一条服务区域(省+市+区县)不允许重复 + $duplicate = Db::name('fc_area') + ->where('province', $province) + ->where('city', $city) + ->where('district', $district) + ->where('id', '<>', $id) + ->find(); + if ($duplicate) { + $this->fail('该省市区组合已存在'); + } + + if ($lat !== null && $lat !== '') { + if (!is_numeric($lat) || (float)$lat < -90 || (float)$lat > 90) { + $this->fail('纬度格式不正确'); + } + $lat = round((float)$lat, 6); + } else { + $lat = null; + } + if ($lng !== null && $lng !== '') { + if (!is_numeric($lng) || (float)$lng < -180 || (float)$lng > 180) { + $this->fail('经度格式不正确'); + } + $lng = round((float)$lng, 6); + } else { + $lng = null; + } + + $now = time(); $data = [ - 'name' => $name, - 'sort' => $sort, - 'enabled' => $enabled ? 1 : 0, + 'province' => $province, + 'city' => $city, + 'district' => $district, + 'lat' => $lat, + 'lng' => $lng, + 'sort' => $sort, + 'enabled' => $enabled ? 1 : 0, + 'updatetime' => $now, ]; if ($id && Db::name('fc_area')->where('id', $id)->find()) { Db::name('fc_area')->where('id', $id)->update($data); } else { + $data['createtime'] = $now; $id = Db::name('fc_area')->insertGetId($data); } - $this->adminLog('保存区域 #' . $id . ' ' . $name); + $fullName = $province . ' / ' . $city . ' / ' . $district; + $this->adminLog('保存服务区域 #' . $id . ' ' . $fullName); - $this->ok(['id' => (int)$id, 'name' => $name]); + $this->ok(['id' => (int)$id, 'fullName' => $fullName]); } /** @@ -178,11 +242,19 @@ class AdminSetting extends V1 if ($this->request->isPost()) { $siteName = $this->input('siteName', null); if ($siteName !== null) { - $this->setCfg('ui.siteName', (string)$siteName); + $siteName = trim((string)$siteName); + if ($siteName === '') { + $this->fail('小程序名称不能为空'); + } + $this->setCfg('base.appName', $siteName); + // 兼容旧 ui.siteName(用户端已统一优先读 base.appName) + $this->setCfg('ui.siteName', $siteName); } $logo = $this->input('logo', null); if ($logo !== null) { + $this->setCfg('base.logo', (string)$logo); + // 兼容旧 ui.logo $this->setCfg('ui.logo', (string)$logo); } @@ -206,7 +278,7 @@ class AdminSetting extends V1 } $this->setCfg('ui.updatedAt', date('c')); - $this->adminLog('修改LOGO与图标配置'); + $this->adminLog('修改小程序名称/LOGO/图标配置'); } $this->ok($this->uiConfig()); @@ -218,17 +290,16 @@ class AdminSetting extends V1 */ public function uiReset() { + // LOGO 已统一到 base.logo,重置时两处都清空,避免旧 ui.logo 回落“复活” + $this->setCfg('base.logo', ''); $this->setCfg('ui.logo', ''); $this->setCfg('ui.icons', []); $this->setCfg('ui.updatedAt', date('c')); $this->adminLog('重置LOGO与图标配置'); - $this->ok([ - 'siteName' => (string)$this->cfg('ui.siteName', '同城分类信息'), - 'logo' => '', - 'icons' => (object)[], - 'reset' => true, - ]); + $config = $this->uiConfig(); + $config['reset'] = true; + $this->ok($config); } /** @@ -275,17 +346,29 @@ class AdminSetting extends V1 ]; } - /** UI 配置结构(icons 保证为对象) */ + /** UI 配置结构(名称/LOGO 以 base.* 为准,icons 保证为对象) */ protected function uiConfig() { $icons = $this->cfg('ui.icons', []); $icons = is_array($icons) ? $icons : (array)$icons; + $baseName = trim((string)$this->cfg('base.appName', '')); + $siteName = $baseName !== '' ? $baseName : (string)$this->cfg('ui.siteName', '同城分类信息'); + + $baseLogo = (string)$this->cfg('base.logo', ''); + $logo = $baseLogo !== '' ? $baseLogo : (string)$this->cfg('ui.logo', ''); + + $shareTitle = trim((string)$this->cfg('base.shareTitle', '')); + return [ - 'siteName' => (string)$this->cfg('ui.siteName', '同城分类信息'), - 'logo' => (string)$this->cfg('ui.logo', ''), - 'icons' => (object)$icons, - 'updatedAt' => (string)$this->cfg('ui.updatedAt', date('c')), + 'appName' => $siteName, + 'siteName' => $siteName, + 'logo' => $logo, + 'servicePhone' => (string)$this->cfg('base.servicePhone', ''), + 'aboutUs' => (string)$this->cfg('base.aboutUs', ''), + 'shareTitle' => $shareTitle !== '' ? $shareTitle : $siteName, + 'icons' => (object)$icons, + 'updatedAt' => (string)$this->cfg('ui.updatedAt', date('c')), ]; } } diff --git a/application/api/controller/Auth.php b/application/api/controller/Auth.php index 7540c1b..4ed79d2 100644 --- a/application/api/controller/Auth.php +++ b/application/api/controller/Auth.php @@ -4,6 +4,7 @@ namespace app\api\controller; use think\Db; use app\common\library\DistributionService; +use app\common\library\WxMini; /** * 用户端 · 认证与登录(Module-AUTH) @@ -31,6 +32,9 @@ class Auth extends V1 $this->fail('缺少微信授权 code'); } + // 频率限制:同一 IP 每分钟最多 30 次授权登录 + $this->rateLimit('wxlogin:' . $this->request->ip(), 30, 60); + $nickname = trim((string)$this->input('nickname', '')); $avatar = trim((string)$this->input('avatar', '')); if (mb_strlen($nickname) > 32) { @@ -42,7 +46,12 @@ class Auth extends V1 $manualPhone = trim((string)$this->input('phone', '')); $phone = ''; if ($phoneCode !== '') { - $phone = $this->resolveWxPhone($phoneCode); + try { + $phone = WxMini::phoneNumber($phoneCode); + } catch (\Throwable $e) { + // 手机号属于可选增强:拿不到不阻断登录,前端可回落到手动绑定(A2 bindPhone) + $phone = ''; + } } elseif ($manualPhone !== '' && preg_match('/^1\d{10}$/', $manualPhone)) { $phone = $manualPhone; } @@ -51,70 +60,86 @@ class Auth extends V1 $inviterId = (int)$this->input('inviterId', $this->input('inviter_id', 0)); $inviteCode = trim((string)$this->input('inviteCode', $this->input('invite_code', ''))); - $ext = Db::name('fc_user_ext')->where('openid', $openid)->find(); - $user = $ext ? Db::name('user')->where('id', $ext['user_id'])->find() : null; + // 同一 openid 串行建号:避免并发首次登录建出两个账号(usage: MySQL GET_LOCK) + $lockName = 'fc_wxlogin_' . md5($openid); + Db::query('SELECT GET_LOCK(?, 5)', [$lockName]); - if (!$user) { - // 首次登录:自动建号(有微信昵称/头像就落库,否则用占位昵称 + 字母头像) - $uid = Db::name('user')->insertGetId([ - 'group_id' => 1, - 'username' => 'wx' . substr(md5($openid), 0, 10), - 'nickname' => $nickname !== '' ? $nickname : '同城用户' . mt_rand(1000, 9999), - 'password' => md5(mt_rand()), - 'salt' => 'wx', - 'email' => substr(md5($openid), 0, 12) . '@wx.local', - 'mobile' => $phone, - 'avatar' => $avatar, - 'level' => 1, - 'money' => 0, - 'score' => 0, - 'status' => 'normal', - 'jointime' => time(), - 'joinip' => $this->request->ip(), - 'createtime' => time(), - 'updatetime' => time(), - ]); - Db::name('fc_user_ext')->insert([ - 'user_id' => $uid, 'openid' => $openid, 'user_type' => 1, 'realname_status' => 0, - 'is_member' => 0, 'member_level' => '', 'member_expiretime' => null, - 'invite_code' => strtoupper(substr(md5('fc' . $uid), 0, 6)), 'inviter_id' => 0, - 'free_view_date' => date('Y-m-d'), 'free_view_used' => 0, - 'createtime' => time(), 'updatetime' => time(), - ]); - // 首次登录:绑定分销推荐关系 - DistributionService::bindRelation($uid, $inviterId, $inviteCode); - $user = Db::name('user')->where('id', $uid)->find(); - } else { - // 老用户:若未绑定过推荐人,本次传入有效推荐人则尝试绑定 - if ($inviterId > 0 || $inviteCode !== '') { - DistributionService::bindRelation($user['id'], $inviterId, $inviteCode); + try { + Db::startTrans(); + + $ext = Db::name('fc_user_ext')->where('openid', $openid)->find(); + $user = $ext ? Db::name('user')->where('id', $ext['user_id'])->find() : null; + + if (!$user) { + // 首次登录:自动建号(有微信昵称/头像就落库,否则用占位昵称 + 字母头像) + $uid = Db::name('user')->insertGetId([ + 'group_id' => 1, + 'username' => 'wx' . substr(md5($openid), 0, 10), + 'nickname' => $nickname !== '' ? $nickname : '同城用户' . mt_rand(1000, 9999), + 'password' => md5(mt_rand()), + 'salt' => 'wx', + 'email' => substr(md5($openid), 0, 12) . '@wx.local', + 'mobile' => $phone, + 'avatar' => $avatar, + 'level' => 1, + 'money' => 0, + 'score' => 0, + 'status' => 'normal', + 'jointime' => time(), + 'joinip' => $this->request->ip(), + 'createtime' => time(), + 'updatetime' => time(), + ]); + Db::name('fc_user_ext')->insert([ + 'user_id' => $uid, 'openid' => $openid, 'user_type' => 1, 'realname_status' => 0, + 'is_member' => 0, 'member_level' => '', 'member_expiretime' => null, + 'invite_code' => strtoupper(substr(md5('fc' . $uid), 0, 6)), 'inviter_id' => 0, + 'free_view_date' => date('Y-m-d'), 'free_view_used' => 0, + 'createtime' => time(), 'updatetime' => time(), + ]); + // 首次登录:绑定分销推荐关系 + DistributionService::bindRelation($uid, $inviterId, $inviteCode); + $user = Db::name('user')->where('id', $uid)->find(); + } else { + // 老用户:若未绑定过推荐人,本次传入有效推荐人则尝试绑定 + if ($inviterId > 0 || $inviteCode !== '') { + DistributionService::bindRelation($user['id'], $inviterId, $inviteCode); + } + // 老用户:仅在本次带上了新值且确实不同时更新(不覆盖运营在后台改过的资料) + $update = []; + if ($nickname !== '' && $nickname !== (string)$user['nickname']) { + $update['nickname'] = $nickname; + } + if ($avatar !== '' && $avatar !== (string)$user['avatar']) { + $update['avatar'] = $avatar; + } + if ($phone !== '' && (string)$user['mobile'] === '') { + $update['mobile'] = $phone; + } + if ($update) { + $update['updatetime'] = time(); + Db::name('user')->where('id', $user['id'])->update($update); + $user = Db::name('user')->where('id', $user['id'])->find(); + } } - // 老用户:仅在本次带上了新值且确实不同时更新(不覆盖运营在后台改过的资料) - $update = []; - if ($nickname !== '' && $nickname !== (string)$user['nickname']) { - $update['nickname'] = $nickname; - } - if ($avatar !== '' && $avatar !== (string)$user['avatar']) { - $update['avatar'] = $avatar; - } - if ($phone !== '' && (string)$user['mobile'] === '') { - $update['mobile'] = $phone; - } - if ($update) { - $update['updatetime'] = time(); - Db::name('user')->where('id', $user['id'])->update($update); - $user = Db::name('user')->where('id', $user['id'])->find(); + + if ($user['status'] != 'normal') { + throw new \RuntimeException('账号已被禁用,请联系客服'); } + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + Db::query('SELECT RELEASE_LOCK(?)', [$lockName]); + $this->fail($e->getMessage() ?: '微信登录失败,请重试'); } - if ($user['status'] != 'normal') { - $this->fail('账号已被禁用,请联系客服'); - } + Db::query('SELECT RELEASE_LOCK(?)', [$lockName]); $token = $this->issueToken($user['id']); $this->ok([ 'token' => $token, - 'refreshToken' => md5($token . 'refresh'), + 'refreshToken' => $this->issueRefreshToken($user['id']), 'userInfo' => $this->userInfo($user), ]); } @@ -141,7 +166,7 @@ class Auth extends V1 * A5 微信手机号快捷获取 * 入参 { code }(`