diff --git a/CHANGELOG.md b/CHANGELOG.md index b2eea37..db58662 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## v1.8 (2026-06-05) — FastAdmin 用户端 + 支付激活闭环 + +### 新增 +- **`fastadmin/chathub/`** — FastAdmin ThinkPHP 5 用户端插件 (11 文件 / 204K) + - **注册流程** — 选套餐 → 创建租户 → 调 chathub-provision 同步开通 → 写入 `embed_code` + - **支付激活闭环** — `_markOrderPaid()` 调 `_provisionAsync()` 补建资源, 避免 "付了款拿不到代码" 困境 + - **`reprovision` action** — 用户主动重试 (登录后访问 `/addons/chathub/index/reprovision?ids={tenant_id}`) + - **`payReturn` smart redirect** — 3 分支: `?just_paid=1` (有资源) / `?provisioning=1` (付了款没资源) / `?pending=1` (订单未确认) / fallback `?order=X` + - **5 渠道绑定 UI** — `channelAuth` / `channelCallback` 完整 OAuth 流程 (Amazon/JD/Taobao/PDD/TikTok) + - **状态徽章** — 新增 `provisioning` 蓝 (#3b82f6), 完整状态机: pending→provisioning→active + - **`fa_chathub_order` 表** — 支付订单 + 续期 + - **`fa_chathub_channel_account` 表** — AES-256-GCM 加密凭据 (5 平台) + - **`fa_chathub_gateway_log` 表** — Gateway 6 错误路径调用日志 + - **`MIGRATIONS.md`** — v1.0 → v1.6 schema 升级脚本 (含 `provisioning` ENUM) +- **`_initialize()` 白名单** — `reprovision` (user) + `payNotify`/`payReturn` (public webhook) 加入 FastAdmin action 白名单 + +### 修复 +- **`payNotify` HTTP 500** — 两层 ENUM schema 同步问题: + 1. `fa_chathub_tenant.status` ENUM 加 `provisioning` (`ALTER TABLE ... MODIFY COLUMN ...`) + 2. `fa_chathub_log.status` 写入了非法值 `'received'`, 改为 `'success'` +- **TP5 method signature bug** — `reprovision($ids = null)` 改 `$this->request->param('ids')` (TP5 不会自动注入 query string) +- **支付流程 E2E 验证** — 4 项改动 (`_markOrderPaid`+`_provisionAsync`, `reprovision`, `payReturn` smart redirect, `provisioning` 状态徽章) 全部通过 E2E + +### 文件统计 +- `controller/Index.php` 1964 → 2108 行 (+144, +1 fix) +- `install.sql` 2 张表 → 5 张表 +- 配置文件 17 个后台可填字段 +- `fastadmin/chathub/README.md` 完整安装/路由/状态机文档 + +### 部署注意 +- 旧用户升级: 跑 `fastadmin/chathub/MIGRATIONS.md` 的 SQL +- 新用户安装: 用 `fastadmin/chathub/install.sql` (5 张表) +- FastAdmin 插件市场安装: 启用后到"插件管理 → ChatHub → 配置"填入 17 个字段 + +--- + ## v1.7 (2026-06-05) — 对话上下文 + 客户画像 + 指数退避重连 ### 新增 diff --git a/README.md b/README.md index f1610f6..054c6ae 100644 --- a/README.md +++ b/README.md @@ -227,9 +227,63 @@ chatwoot-ai-agent/ ├── requirements.txt # Python 依赖 ├── chatwoot_auth.example.json # Session 认证文件模板 ├── inboxes.example.json # 路由配置模板 +├── fastadmin/ # FastAdmin 用户端 PHP 插件 (v1.8+) +│ └── chathub/ # 用户注册/付费/会员中心 + 渠道绑定 UI +│ ├── controller/Index.php # 2108 行 (HTML 内联, 14 个 action) +│ ├── model/ChathubTenant.php +│ ├── config.php # 17 个后台可填配置项 +│ ├── install.sql # 5 张表 (tenant/log/order/channel_account/gateway_log) +│ ├── MIGRATIONS.md # v1.0 → v1.6 schema 升级脚本 +│ ├── assets/css|js/ # 样式 + 表格初始化 +│ └── README.md # 安装 + 路由表 + 生命周期 └── .gitignore ``` +## FastAdmin 用户端 (`fastadmin/chathub/`) + +PHP 前端 (ThinkPHP 5 / FastAdmin addon),为整个系统提供用户面:注册 → 选套餐 → 支付 → 会员中心 → 渠道绑定。所有 HTML 都在 `controller/Index.php` 里以 PHP heredoc 形式内联,不依赖 FastAdmin 模板引擎。 + +### 路由表 + +| URL | Auth | 用途 | +|---|---|---| +| `/addons/chathub/index/landing` | public | 落地页 | +| `/addons/chathub/index/register` | public | 注册 + 选套餐 | +| `/addons/chathub/index/login` / `doLogin` / `logout` | public | 登录登出 | +| `/addons/chathub/index/my` | user (session) | 会员中心 (租户列表 + 嵌入代码 + 重新开通) | +| `/addons/chathub/index/channelList` / `channelAuth` / `channelCallback` | user | 5 平台渠道绑定 | +| `/addons/chathub/index/reprovision` | user | 用户主动补建资源 | +| `/addons/chathub/index/payAlipay` / `payWechat` | user | 发起支付 | +| `/addons/chathub/index/payNotify` | public (webhook) | 支付宝/微信异步回调 (验签 + 开户) | +| `/addons/chathub/index/payReturn` | public (webhook) | 支付宝/微信同步跳转 (3 分支 smart redirect) | + +### 与 chathub-provision 的契约 + +`payNotify` 和 `register` 调用 `chathub-provision` 服务的 `/provision` 端点: + +```http +POST {provision_server_url}/provision +Headers: X-API-Key, Idempotency-Key, Content-Type: application/json +Body: {"name": "...", "domain": "...", "email": "...", "type": "web_widget", "max_agents": 3} +``` + +`Idempotency-Key` 用 `reprov-{tenant_id}-{md5(domain|channel_type)}` 计算,允许 PHP 重试时 chathub-provision 端去重 (5 分钟 TTL)。 + +### 状态机 + +``` +register → status='provisioning' ─┬─ success → status='active' (embed_code 写入) + │ + └─ failure → status='pending' (用户点"重新开通") + +payNotify → _markOrderPaid() + _provisionAsync() (若 embed_code 为空) + └─ success → status='active' + +reprovision → 用户主动重试 +``` + +详见 `fastadmin/chathub/README.md`。 + ## 版本历史 | 版本 | 说明 | @@ -242,6 +296,7 @@ chatwoot-ai-agent/ | v1.5 | 消息防抖(5s 累积合并),AI 错误重试(指数退避)| | v1.6 | Platform Gateway 库——Amazon/JD/Taobao/PDD/TikTok 5 平台统一 API 集成 | | v1.7 | 对话上下文(`--session-id`)+ 对话摘要 + 客户画像 + WebSocket 指数退避重连 | +| v1.8 | FastAdmin 用户端 PHP 插件 (`fastadmin/chathub/`) ——注册/选套餐/支付/会员中心/5 渠道绑定, 支付完成后自动调 chathub-provision 补建资源, 修复 `payNotify` HTTP 500 (ENUM schema + log status bug), 新增 `reprovision` 用户主动重试 | ## 许可证 diff --git a/fastadmin/chathub/Chathub.php b/fastadmin/chathub/Chathub.php new file mode 100644 index 0000000..d40f985 --- /dev/null +++ b/fastadmin/chathub/Chathub.php @@ -0,0 +1,68 @@ + 'chathub', + 'title' => 'ChatHub', + 'icon' => 'fa fa-headset', + 'ismenu' => 1, + 'weigh' => 1, + 'sublist' => [ + ["name" => "chathub/index/index", "title" => "租户列表"], + ["name" => "chathub/index/dashboard", "title" => "控制面板"], + ] + ] + ]; + + public function install() + { + Menu::create($this->menu); + return true; + } + + public function uninstall() + { + Menu::delete("chathub"); + return true; + } + + public function enable() + { + Menu::enable("chathub"); + return true; + } + + public function disable() + { + Menu::disable("chathub"); + return true; + } + + public function upgrade() + { + Menu::upgrade('chathub', $this->menu); + return true; + } + + /** + * config_init 钩子 + */ + public function ConfigInit() + { + // nothing to init + } + + /** + * 兼容 fallback + */ + public function run() + { + // nothing + } +} diff --git a/fastadmin/chathub/LICENSE b/fastadmin/chathub/LICENSE new file mode 100644 index 0000000..3df8a71 --- /dev/null +++ b/fastadmin/chathub/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 GreatQiu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/fastadmin/chathub/MIGRATIONS.md b/fastadmin/chathub/MIGRATIONS.md new file mode 100644 index 0000000..3bc4618 --- /dev/null +++ b/fastadmin/chathub/MIGRATIONS.md @@ -0,0 +1,69 @@ +# Chathub FastAdmin Addon — Migrations + +This file documents schema migrations applied to the chathub tables between releases. Always back up your database before applying migrations. + +## v1.6 — channel_type + status enum extension + +**Date:** 2026-06-05 +**Reason:** Support 5 platform integrations (Amazon/京东/淘宝/拼多多/抖音) and add explicit "provisioning" state in tenant lifecycle. + +### `fa_chathub_tenant` + +```sql +ALTER TABLE `fa_chathub_tenant` + ADD COLUMN `user_id` INT(11) UNSIGNED DEFAULT NULL COMMENT 'fa_user.id (创建者)' AFTER `id`, + ADD COLUMN `team_id` INT(11) DEFAULT NULL COMMENT 'Chatwoot Team ID' AFTER `inbox_token`, + ADD COLUMN `max_agents` INT(11) NOT NULL DEFAULT 3 COMMENT '最大坐席数' AFTER `agent_name`, + ADD COLUMN `expire_at` DATETIME DEFAULT NULL COMMENT '到期时间' AFTER `provisioned_at`, + ADD KEY `idx_user_id` (`user_id`), + MODIFY COLUMN `channel_type` ENUM('web_widget','api','amazon','jd','taobao','pdd','tiktok') NOT NULL DEFAULT 'web_widget' COMMENT '通道类型', + MODIFY COLUMN `status` ENUM('pending','provisioning','active','suspended','disabled') NOT NULL DEFAULT 'pending' COMMENT '状态'; +``` + +### `fa_chathub_log` + +```sql +ALTER TABLE `fa_chathub_log` + MODIFY COLUMN `tenant_id` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID (0=系统级)'; +``` + +### New tables + +```sql +-- v1.8 支付订单 +CREATE TABLE IF NOT EXISTS `fa_chathub_order` ( + ... (see install.sql for full schema) +); + +-- v1.6 渠道账号 (5 平台) +CREATE TABLE IF NOT EXISTS `fa_chathub_channel_account` ( + ... (see install.sql for full schema) +); + +-- v1.6 Gateway 调用日志 +CREATE TABLE IF NOT EXISTS `fa_chathub_gateway_log` ( + ... (see install.sql for full schema) +); +``` + +## Rollback + +```sql +-- v1.6 rollback +ALTER TABLE `fa_chathub_tenant` + DROP COLUMN `user_id`, + DROP COLUMN `team_id`, + DROP COLUMN `max_agents`, + DROP COLUMN `expire_at`, + DROP KEY `idx_user_id`, + MODIFY COLUMN `channel_type` ENUM('web_widget','api') NOT NULL DEFAULT 'web_widget' COMMENT '通道类型', + MODIFY COLUMN `status` ENUM('pending','active','suspended','disabled') NOT NULL DEFAULT 'pending' COMMENT '状态'; +DROP TABLE IF EXISTS `fa_chathub_order`; +DROP TABLE IF EXISTS `fa_chathub_channel_account`; +DROP TABLE IF EXISTS `fa_chathub_gateway_log`; +``` + +## Notes + +- The `provisioning` status was added because the previous 4-state machine (pending→active→suspended→disabled) had no slot for "paid but Chatwoot resources not yet provisioned". After payNotify, the tenant enters `provisioning` briefly while the chathub-provision service creates Inbox/Team/Agent, then transitions to `active`. If provisioning fails, status reverts to `pending` and the user can click "重新开通" to retry. +- The `channel_type` enum now has 7 values. The original 2 (web_widget/api) are still functional; the 5 new ones (amazon/jd/taobao/pdd/tiktok) are routed through `gateway/` Python library on the WS agent side. diff --git a/fastadmin/chathub/README.md b/fastadmin/chathub/README.md new file mode 100644 index 0000000..dd6d6e5 --- /dev/null +++ b/fastadmin/chathub/README.md @@ -0,0 +1,146 @@ +# FastAdmin ChatHub Addon + +PHP frontend for the Chatwoot AI multi-tenant SaaS system, packaged as a FastAdmin addon (ThinkPHP 5). + +## What this is + +A self-contained FastAdmin plugin that provides the **user-facing** side of the platform: registration, plan selection, payment (Alipay/WeChat), member center, and channel credential management. All HTML is inlined in the controller (no `view/` templates, no FastAdmin render engine) — see "Architecture" below for why. + +The **service-side** lives in this same monorepo, one level up: `gateway/` (in-process Python library), `chatwoot_ws_agent.py` (WebSocket agent), and `provision_server.py` (HTTP provisioning API). The addon talks to those via HTTP only. + +## Architecture + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ FastAdmin ChatHub Addon (this dir) │ +│ │ +│ public/ → register / landing / my / channelAuth / payNotify │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ controller/Index.php (2108 lines) │ │ +│ │ • All HTML inlined as PHP heredoc │ │ +│ │ • POST to chathub-provision over HTTP/JSON │ │ +│ │ • _initialize() whitelists public + user actions │ │ +│ │ • Idempotency-Key for safe webhook retries │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ model/ChathubTenant.php → TP5 model with JSON getters │ +│ config.php → 17 admin-fillable config fields │ +│ install.sql → 5 tables (tenant/log/order/...) │ +└──────────────────────────────────┬───────────────────────────────┘ + │ HTTP/JSON + ▼ + ┌──────────────────────────────────────┐ + │ chathub-provision (Python service) │ + │ :5566 /provision /suspend ... │ + └──────────────────────────────────────┘ +``` + +### Why no `view/` directory? + +FastAdmin's template engine uses ThinkPHP's `view()` / `fetch()` with ThinkTemplate syntax. For this addon, we deliberately use pure `echo` + PHP heredoc strings inside the controller because: + +1. **Single-file deploys** — the entire UI for `my()` and `register()` is in `controller/Index.php`, no template files to keep in sync. +2. **Dynamic flash messages** — the `FLASH_MESSAGE` placeholder is replaced via `str_replace` at the end of `my()` based on query string params (`?just_paid=1` / `?provisioning=1` / `?pending=1` / `?error=...`). +3. **No template-engine dependency** — works on any ThinkPHP 5 install without `think-template` package. + +If you need to customize the UI, edit the heredoc strings inside `controller/Index.php` directly. Search for `register()` and `my()` for the two main render blocks. + +## Routes (URL → controller action) + +| URL | Action | Auth | Purpose | +|---|---|---|---| +| `/addons/chathub/index/landing` | `landing` | public | Landing page | +| `/addons/chathub/index/register` | `register` | public | Registration + plan selection | +| `/addons/chathub/index/login` | `login` | public | Login form | +| `/addons/chathub/index/doLogin` | `doLogin` | public | Login submit | +| `/addons/chathub/index/logout` | `logout` | public | Logout | +| `/addons/chathub/index/my` | `my` | user (session) | Member center (tenant list) | +| `/addons/chathub/index/channelList` | `channelList` | user | Choose a channel to bind | +| `/addons/chathub/index/channelAuth` | `channelAuth` | user | OAuth redirect (5 platforms) | +| `/addons/chathub/index/channelCallback` | `channelCallback` | user | OAuth callback | +| `/addons/chathub/index/reprovision` | `reprovision` | user | Manually retry provisioning | +| `/addons/chathub/index/payAlipay` | `payAlipay` | user | Start Alipay payment | +| `/addons/chathub/index/payWechat` | `payWechat` | user | Start WeChat payment | +| `/addons/chathub/index/payNotify` | `payNotify` | public (webhook) | Alipay/WeChat async callback | +| `/addons/chathub/index/payReturn` | `payReturn` | public (webhook) | Alipay/WeChat sync return | + +## Install + +### 1. Copy addon directory + +```bash +cp -r fastadmin/chathub /www/sites//index/addons/ +``` + +### 2. Run SQL + +The 5 tables are defined in `install.sql` (uses `__PREFIX__` placeholder for FastAdmin table prefix). Run via phpMyAdmin or `mysql`: + +```bash +mysql -u -p < install.sql +``` + +The script will substitute `__PREFIX__` with `fa_` (or whatever your `prefix` config is) — FastAdmin's `db()->execute()` does this automatically, or you can `sed` first. + +If you already have a v1.0–v1.5 install, run `MIGRATIONS.md` instead. + +### 3. Enable the addon + +FastAdmin Admin → 插件管理 → local install → upload this dir → enable. + +### 4. Configure + +FastAdmin Admin → 插件管理 → ChatHub → 配置: + +| Field | Example | Required | +|---|---|---| +| `provision_server_url` | `http://CoPaw:5566` | yes | +| `site_name` | `ChatHub` | yes | +| `chatwoot_url` | `https://chatwoot.example.com` | yes | +| `chatwoot_api_token` | (your personal access token) | yes | +| `alipay_app_id` / `alipay_merchant_private_key` / `alipay_public_key` | (from 支付宝开放平台) | if 支付宝 enabled | +| `wechat_app_id` / `wechat_mch_id` / `wechat_api_v3_key` / `wechat_cert_path` / `wechat_key_path` | (from 微信支付商户平台) | if 微信支付 enabled | + +**Important:** All payment fields use sandbox values by default. Switch off `alipay_sandbox` / `wechat_sandbox` for production. + +### 5. Verify + +```bash +curl -I https:///addons/chathub/index/landing +# should be HTTP 200 +``` + +## Tenant lifecycle + +``` +register() → status='provisioning' (called from chathub-provision sync) + │ + ├─ success → status='active' (embed_code populated) + │ + └─ failure → status='pending' (user can retry from my.html) + +payNotify() → _markOrderPaid() + _provisionAsync() if embed_code empty + │ + └─ _provisionAsync() success → status='active' + embed_code written + +reprovision() → user-initiated retry; same path as payNotify fallback +``` + +The `provisioning` state is **new in v1.6** (see MIGRATIONS.md). Before that, the state machine went straight `pending → active`, which meant a payment that succeeded but the chathub-provision call timed out left the user in a "paid but no embed code" limbo. + +## Changelog (this component) + +- **v1.8** — Payment flow completion: `_markOrderPaid` calls `_provisionAsync` on empty `embed_code`; new `reprovision` action; `payReturn` smart redirect (3 branches); `provisioning` state badge; schema migration +- **v1.6** — Channel bindings (`channelAuth` / `channelCallback`) for Amazon/JD/Taobao/PDD/TikTok +- **v1.0** — Initial release: register/login/my/landing + +## License + +AGPL v3 — see `LICENSE` and root `LICENSE`. + +## Related + +- `../gateway/` — Platform Gateway Python library (5 platform adapters) +- `../chatwoot_ws_agent.py` — WebSocket agent that calls Gateway +- `../provision_server.py` — HTTP provisioning API that the addon calls +- `../CHANGELOG.md` — Top-level changelog diff --git a/fastadmin/chathub/assets/css/chathub.css b/fastadmin/chathub/assets/css/chathub.css new file mode 100644 index 0000000..9109d8c --- /dev/null +++ b/fastadmin/chathub/assets/css/chathub.css @@ -0,0 +1,203 @@ +/* ChatHub 租户管理样式 */ + +:root { + --chathub-primary: #6366f1; + --chathub-primary-dark: #4f46e5; + --chathub-success: #10b981; + --chathub-warning: #f59e0b; + --chathub-danger: #ef4444; +} + +/* 全局过渡效果 */ +.chathub-dashboard *, +.chathub-list *, +.chathub-form * { + transition: all 0.2s ease; +} + +/* 加载动画 */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.fade-in-up { + animation: fadeInUp 0.5s ease forwards; +} + +/* 脉冲动画 */ +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.pulse { + animation: pulse 2s infinite; +} + +/* 响应式调整 */ +@media (max-width: 768px) { + .chathub-dashboard { + padding: 1rem; + } + + .chathub-header { + padding: 1.5rem; + } + + .chathub-header h1 { + font-size: 1.75rem; + } + + .stats-grid { + grid-template-columns: 1fr 1fr; + } + + .content-grid { + grid-template-columns: 1fr; + } + + .form-row { + grid-template-columns: 1fr; + } + + .channel-options { + grid-template-columns: 1fr; + } +} + +@media (max-width: 480px) { + .stats-grid { + grid-template-columns: 1fr; + } + + .quick-actions { + grid-template-columns: 1fr; + } +} + +/* 滚动条美化 */ +.chathub-dashboard::-webkit-scrollbar, +.chathub-list::-webkit-scrollbar, +.chathub-form::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.chathub-dashboard::-webkit-scrollbar-track, +.chathub-list::-webkit-scrollbar-track, +.chathub-form::-webkit-scrollbar-track { + background: #f1f5f9; + border-radius: 4px; +} + +.chathub-dashboard::-webkit-scrollbar-thumb, +.chathub-list::-webkit-scrollbar-thumb, +.chathub-form::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 4px; +} + +.chathub-dashboard::-webkit-scrollbar-thumb:hover, +.chathub-list::-webkit-scrollbar-thumb:hover, +.chathub-form::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} + +/* 工具提示 */ +[data-tooltip] { + position: relative; +} + +[data-tooltip]:hover::after { + content: attr(data-tooltip); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background: #1f2937; + color: white; + padding: 0.5rem 0.75rem; + border-radius: 6px; + font-size: 0.75rem; + white-space: nowrap; + z-index: 1000; +} + +/* 表格行悬停效果 */ +.table tbody tr { + cursor: pointer; +} + +.table tbody tr:hover { + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); +} + +/* 状态徽章动画 */ +.status-badge { + transition: all 0.2s ease; +} + +.status-badge:hover { + transform: scale(1.05); +} + +/* 按钮加载状态 */ +.btn.loading { + pointer-events: none; + opacity: 0.7; +} + +.btn.loading::after { + content: ''; + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid rgba(255,255,255,0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.6s linear infinite; + margin-left: 0.5rem; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* 成功/失败动画 */ +.success-animation { + animation: successPulse 0.5s ease; +} + +@keyframes successPulse { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.1); + } + 100% { + transform: scale(1); + } +} + +/* 空状态图标旋转 */ +.empty-state i { + transition: transform 0.3s ease; +} + +.empty-state:hover i { + transform: rotate(10deg); +} diff --git a/fastadmin/chathub/assets/js/chathub.js b/fastadmin/chathub/assets/js/chathub.js new file mode 100644 index 0000000..a3b2967 --- /dev/null +++ b/fastadmin/chathub/assets/js/chathub.js @@ -0,0 +1,251 @@ +/** + * ChatHub 租户管理 JavaScript + */ +define(['jquery', 'bootstrap', 'template'], function ($, Bootstrap, Template) { + var Controller = { + index: function () { + // 初始化表格 + Controller.api.initTable(); + + // 绑定事件 + Controller.api.bindEvents(); + }, + + add: function () { + // 初始化表单 + Controller.api.initForm(); + }, + + edit: function () { + // 初始化表单 + Controller.api.initForm(); + + // 填充数据 + Controller.api.fillFormData(); + }, + + dashboard: function () { + // 初始化仪表盘 + Controller.api.initDashboard(); + }, + + api: { + // 初始化表格 + initTable: function () { + // 加载租户列表 + Controller.api.loadTenants(); + + // 绑定筛选事件 + $('.filter-select, .filter-input').on('change', function () { + Controller.api.loadTenants(1); + }); + }, + + // 加载租户列表 + loadTenants: function (page) { + page = page || 1; + + $.ajax({ + url: Fast.api.fixurl('chathub/index/index'), + type: 'GET', + data: { + page: page, + filter: JSON.stringify(Controller.api.getFilters()) + }, + success: function (res) { + if (res.rows && res.rows.length > 0) { + Controller.api.renderTenants(res.rows); + Controller.api.renderPagination(res.total, page); + $('#empty-state').hide(); + $('.table-container table').show(); + $('.pagination-wrapper').show(); + } else { + $('#empty-state').show(); + $('.table-container table').hide(); + $('.pagination-wrapper').hide(); + } + } + }); + }, + + // 获取筛选条件 + getFilters: function () { + return { + status: $('.filter-select').eq(0).val(), + channel_type: $('.filter-select').eq(1).val(), + search: $('.filter-input').val() + }; + }, + + // 渲染租户列表 + renderTenants: function (tenants) { + var html = ''; + tenants.forEach(function (tenant) { + var statusClass = 'status-' + tenant.status; + var channelIcon = tenant.channel_type === 'web_widget' ? 'fa-globe' : 'fa-code'; + var channelText = tenant.channel_type_text || (tenant.channel_type === 'web_widget' ? '网页组件' : 'API接口'); + + html += ''; + html += ''; + html += '
'; + html += '
' + tenant.tenant_name.charAt(0).toUpperCase() + '
'; + html += '
'; + html += '

' + tenant.tenant_name + '

'; + html += '

' + (tenant.agent_name || '未配置') + '

'; + html += '
'; + html += '
'; + html += ''; + html += '' + tenant.domain + ''; + html += ' ' + channelText + ''; + html += '' + tenant.status_text + ''; + html += '' + (tenant.provisioned_at || '-') + ''; + html += ''; + html += '
'; + html += ' '; + if (tenant.status !== 'active') { + html += ' '; + } + html += ' '; + html += '
'; + html += ''; + html += ''; + }); + $('#tenant-list').html(html); + }, + + // 渲染分页 + renderPagination: function (total, currentPage) { + var pageSize = 10; + var totalPages = Math.ceil(total / pageSize); + var html = ''; + + if (currentPage > 1) { + html += '上一页'; + } + + for (var i = 1; i <= totalPages; i++) { + if (i === currentPage) { + html += '' + i + ''; + } else { + html += '' + i + ''; + } + } + + if (currentPage < totalPages) { + html += '下一页'; + } + + $('#pagination').html(html); + $('#pagination-info').text('显示 ' + ((currentPage-1)*pageSize+1) + '-' + Math.min(currentPage*pageSize, total) + ' 条,共 ' + total + ' 条'); + }, + + // 开通租户 + provisionTenant: function (id) { + if (confirm('确定要开通这个租户吗?')) { + Fast.api.ajax({ + url: Fast.api.fixurl('chathub/index/provision/ids/' + id), + type: 'POST' + }, function (res) { + Controller.api.loadTenants(); + }); + } + }, + + // 删除租户 + deleteTenant: function (id) { + if (confirm('确定要删除这个租户吗?此操作不可恢复。')) { + Fast.api.ajax({ + url: Fast.api.fixurl('chathub/index/del/ids/' + id), + type: 'POST' + }, function (res) { + Controller.api.loadTenants(); + }); + } + }, + + // 初始化表单 + initForm: function () { + // 通道类型选择 + $('.channel-option').click(function () { + $('.channel-option').removeClass('active'); + $(this).addClass('active'); + $('input[name="row[channel_type]"]').val($(this).data('value')); + }); + + // 表单提交 + $('#tenant-form').on('submit', function (e) { + e.preventDefault(); + + var formData = $(this).serialize(); + var url = $(this).attr('action'); + + $.ajax({ + url: url, + type: 'POST', + data: formData, + success: function (res) { + if (res.code === 1) { + Fast.api.msg('操作成功!'); + setTimeout(function () { + location.href = Fast.api.fixurl('chathub/index/index'); + }, 1000); + } else { + Fast.api.msg(res.msg || '操作失败', 'danger'); + } + }, + error: function () { + Fast.api.msg('网络错误,请重试', 'danger'); + } + }); + }); + }, + + // 填充表单数据 + fillFormData: function () { + // 从页面获取数据并填充 + var row = window.rowData || {}; + + if (row.tenant_name) { + $('input[name="row[tenant_name]"]').val(row.tenant_name); + } + if (row.domain) { + $('input[name="row[domain]"]').val(row.domain); + } + if (row.agent_name) { + $('input[name="row[agent_name]"]').val(row.agent_name); + } + if (row.status) { + $('select[name="row[status]"]').val(row.status); + } + if (row.channel_type) { + $('.channel-option').removeClass('active'); + $('.channel-option[data-value="' + row.channel_type + '"]').addClass('active'); + $('input[name="row[channel_type]"]').val(row.channel_type); + } + }, + + // 初始化仪表盘 + initDashboard: function () { + // 添加动画效果 + $('.stat-card').each(function (index) { + $(this).css({ + 'opacity': '0', + 'transform': 'translateY(20px)', + 'animation': 'fadeInUp 0.5s ease forwards', + 'animation-delay': (index * 0.1) + 's' + }); + }); + }, + + // 绑定事件 + bindEvents: function () { + // 刷新按钮 + $(document).on('click', '.btn-refresh', function () { + Controller.api.loadTenants(); + }); + } + } + }; + + return Controller; +}); diff --git a/fastadmin/chathub/config.php b/fastadmin/chathub/config.php new file mode 100644 index 0000000..62a05be --- /dev/null +++ b/fastadmin/chathub/config.php @@ -0,0 +1,225 @@ + 'provision_server_url', + 'title' => 'Provision 服务地址', + 'type' => 'string', + 'group' => '系统', + 'content' => [], + 'value' => 'http://CoPaw:5566', + 'rule' => 'required', + 'msg' => '', + 'tip' => 'QwenPaw 环境内的 Provision HTTP 服务地址', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'site_name', + 'title' => '站点名称', + 'type' => 'string', + 'group' => '系统', + 'content' => [], + 'value' => 'ChatHub', + 'rule' => '', + 'msg' => '', + 'tip' => '产品名称,显示在页面标题和导航', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'support_email', + 'title' => '客服邮箱', + 'type' => 'string', + 'group' => '系统', + 'content' => [], + 'value' => '', + 'rule' => 'email', + 'msg' => '', + 'tip' => '用户联系邮箱,注册成功通知会发到这里', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'chatwoot_url', + 'title' => 'Chatwoot 地址', + 'type' => 'string', + 'group' => 'Chatwoot', + 'content' => [], + 'value' => 'https://chatwoot.275763.xyz', + 'rule' => 'required', + 'msg' => '', + 'tip' => 'Chatwoot 自托管地址', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'chatwoot_api_token', + 'title' => 'Chatwoot API Token', + 'type' => 'string', + 'group' => 'Chatwoot', + 'content' => [], + 'value' => '', + 'rule' => 'required', + 'msg' => '', + 'tip' => 'Chatwoot 个人 Access Token(设置 → Profile → Access Token)', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'alipay_enabled', + 'title' => '启用支付宝', + 'type' => 'switch', + 'group' => '支付宝', + 'content' => [], + 'value' => '0', + 'rule' => '', + 'msg' => '', + 'tip' => '开启后注册/续费页显示支付宝支付选项', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'alipay_app_id', + 'title' => 'APPID', + 'type' => 'string', + 'group' => '支付宝', + 'content' => [], + 'value' => '', + 'rule' => '', + 'msg' => '', + 'tip' => '支付宝开放平台应用 APPID(沙箱/生产不同)', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'alipay_merchant_private_key', + 'title' => '应用私钥', + 'type' => 'textarea', + 'group' => '支付宝', + 'content' => [], + 'value' => '', + 'rule' => '', + 'msg' => '', + 'tip' => 'RSA2 私钥(应用公钥对应的私钥),-----BEGIN PRIVATE KEY----- 开头', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'alipay_public_key', + 'title' => '支付宝公钥', + 'type' => 'textarea', + 'group' => '支付宝', + 'content' => [], + 'value' => '', + 'rule' => '', + 'msg' => '', + 'tip' => '支付宝公钥(不是应用公钥),用于验证回调签名', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'alipay_sandbox', + 'title' => '沙箱模式', + 'type' => 'switch', + 'group' => '支付宝', + 'content' => [], + 'value' => '1', + 'rule' => '', + 'msg' => '', + 'tip' => '开启=沙箱环境(测试用),关闭=正式环境', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'wechat_enabled', + 'title' => '启用微信支付', + 'type' => 'switch', + 'group' => '微信支付', + 'content' => [], + 'value' => '0', + 'rule' => '', + 'msg' => '', + 'tip' => '开启后注册/续费页显示微信支付选项', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'wechat_app_id', + 'title' => 'APPID', + 'type' => 'string', + 'group' => '微信支付', + 'content' => [], + 'value' => '', + 'rule' => '', + 'msg' => '', + 'tip' => '微信开放平台/公众平台 APPID', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'wechat_mch_id', + 'title' => '商户号', + 'type' => 'string', + 'group' => '微信支付', + 'content' => [], + 'value' => '', + 'rule' => '', + 'msg' => '', + 'tip' => '微信支付商户号', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'wechat_api_v3_key', + 'title' => 'APIv3 密钥', + 'type' => 'string', + 'group' => '微信支付', + 'content' => [], + 'value' => '', + 'rule' => '', + 'msg' => '', + 'tip' => '微信支付 APIv3 密钥(商户平台 → API安全 → APIv3密钥)', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'wechat_cert_path', + 'title' => '商户证书路径', + 'type' => 'string', + 'group' => '微信支付', + 'content' => [], + 'value' => '', + 'rule' => '', + 'msg' => '', + 'tip' => 'apiclient_cert.pem 绝对路径,例如 /www/sites/hub/certs/apiclient_cert.pem', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'wechat_key_path', + 'title' => '商户私钥路径', + 'type' => 'string', + 'group' => '微信支付', + 'content' => [], + 'value' => '', + 'rule' => '', + 'msg' => '', + 'tip' => 'apiclient_key.pem 绝对路径', + 'ok' => '', + 'extend' => '', + ], + [ + 'name' => 'wechat_sandbox', + 'title' => '测试模式', + 'type' => 'switch', + 'group' => '微信支付', + 'content' => [], + 'value' => '1', + 'rule' => '', + 'msg' => '', + 'tip' => '开启=测试号环境,关闭=正式环境', + 'ok' => '', + 'extend' => '', + ], +]; diff --git a/fastadmin/chathub/controller/Index.php b/fastadmin/chathub/controller/Index.php new file mode 100644 index 0000000..1088e9d --- /dev/null +++ b/fastadmin/chathub/controller/Index.php @@ -0,0 +1,2108 @@ +request->action(), $publicActions)) { + $this->model = new ChathubTenant(); + return; + } + $userActions = ['my', 'channellist', 'channelauth', 'channelcallback', 'reprovision']; + if (session('chathub_user_id') && in_array($this->request->action(), $userActions)) { + $this->model = new ChathubTenant(); + return; + } + parent::_initialize(); + $this->model = new ChathubTenant(); + } + + private function render($content) + { + header('Content-Type: text/html; charset=utf-8'); + echo $content; + exit; + } + + private function layout($title, $body) + { + return '' . $title . ' + + + + + +
' . $body . '
+'; + } + + public function index() + { + if ($this->request->isAjax()) { + $filter = $this->request->request("filter"); + $filter = (array)json_decode($filter, true); + $where = []; + if (!empty($filter['status'])) $where['status'] = $filter['status']; + if (!empty($filter['channel_type'])) $where['channel_type'] = $filter['channel_type']; + if (!empty($filter['search'])) $where['tenant_name|domain'] = ['like', '%' . $filter['search'] . '%']; + $list = $this->model->where($where)->order('id desc')->paginate(); + return json(["total" => $list->total(), "rows" => $list->items()]); + } + + $html = '
+

租户管理

+ +
+ +
租户域名邮箱通道席位状态到期时间操作
+ +
+ +
+
'; + + $html .= ''; + + $this->render($this->layout('租户列表', $html)); + } + + public function add() + { + if ($this->request->isPost()) { + $params = $this->request->post("row/a"); + if ($params) { + Db::startTrans(); + try { + $result = $this->model->allowField(true)->save($params); + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + if ($result !== false) { + $this->success('添加成功!', '/pGTnXkhdmy.php/addons/chathub/index/index'); + } + $this->error($this->model->getError()); + } + $this->error('参数不能为空'); + } + + $html = ' +

添加租户

+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
'; + + $this->render($this->layout('添加租户', $html)); + } + + public function register() + { + header('Access-Control-Allow-Origin: *'); + header('Access-Control-Allow-Methods: POST, OPTIONS'); + header('Access-Control-Allow-Headers: Content-Type'); + + if ($this->request->isOptions()) { + exit(json_encode(['code' => 1])); + } + + $isAjax = $this->request->isAjax() || strpos($this->request->header('content-type', ''), 'json') !== false; + $isJson = $isAjax; + + $rawInput = file_get_contents('php://input'); + $jsonData = json_decode($rawInput, true); + + if ($jsonData) { + $data = $jsonData; + } else { + $data = $this->request->post(); + } + + $companyName = trim($data['company_name'] ?? ''); + $email = trim($data['email'] ?? ''); + $domain = trim($data['domain'] ?? ''); + $plan = $data['plan'] ?? 'basic'; + $seats = (int)($data['seats'] ?? 0); + $notes = trim($data['notes'] ?? ''); + + if (!$companyName || !$email || !$domain) { + if ($isJson) { + header('Content-Type: application/json; charset=utf-8'); + exit(json_encode(['code' => 0, 'msg' => '请填写必填字段'])); + } + $this->_registerResultPage(false, '请填写必填字段(公司名称、邮箱、网站域名)'); + return; + } + + $baseSeats = 1; + $basePrice = 50; + $extraSeatPrice = 10; + + if ($plan === 'pro') { + $baseSeats = 2; + $basePrice = 60; + } elseif ($plan === 'enterprise') { + $baseSeats = 3; + $basePrice = 70; + $basePrice += $seats * $extraSeatPrice; + } + + $totalSeats = $baseSeats + $seats; + $expireAt = date('Y-m-d H:i:s', strtotime('+30 days')); + + $existingUser = Db::name('user')->where('email', $email)->find(); + if (!$existingUser) { + $defaultPassword = substr(md5($email . time() . 'chathub'), 0, 10); + $userId = Db::name('user')->insertGetId([ + 'username' => $email, + 'nickname' => $companyName, + 'email' => $email, + 'password' => password_hash($defaultPassword, PASSWORD_BCRYPT), + 'salt' => '', + 'status' => 'normal', + 'jointime' => time(), + 'joinip' => $this->request->ip(), + 'createtime' => time(), + 'updatetime' => time(), + ]); + $createdPassword = $defaultPassword; + } else { + $userId = $existingUser['id']; + $createdPassword = null; + } + + $tenant = new ChathubTenant(); + $tenant->user_id = $userId; + $tenant->tenant_name = $companyName; + $tenant->domain = $domain; + $tenant->email = $email; + $tenant->channel_type = 'web_widget'; + $tenant->max_agents = $totalSeats; + $tenant->status = 'provisioning'; + $tenant->expire_at = $expireAt; + $tenant->config = json_encode(['plan' => $plan, 'price' => $basePrice, 'notes' => $notes, 'registered_at' => date('Y-m-d H:i:s'), 'initial_password' => $createdPassword]); + $tenant->save(); + + $cfg = get_addon_config('chathub'); + $provisionUrl = rtrim($cfg['provision_server_url'] ?? 'http://CoPaw:5566', '/') . '/provision'; + $payload = json_encode([ + 'name' => $companyName, + 'domain' => $domain, + 'email' => $email, + 'type' => 'web_widget', + 'max_agents' => $totalSeats, + ]); + + $idempotencyKey = 'reg-' . bin2hex(random_bytes(8)); + $ch = curl_init($provisionUrl); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-API-Key: chathub-default-key-change-me', 'Idempotency-Key: ' . $idempotencyKey], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 60, + CURLOPT_CONNECTTIMEOUT => 10 + ]); + $response = null; + $curlError = ''; + $httpCode = 0; + $attempts = 0; + $maxAttempts = 3; + + while ($attempts < $maxAttempts) { + $attempts++; + $response = curl_exec($ch); + $curlError = curl_error($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if (!$curlError && $httpCode == 200) { + break; + } + + $tenant->config = json_encode(array_merge( + json_decode($tenant->config, true) ?: [], + ['provision_retry' => $attempts, 'last_error' => $curlError ?: "HTTP $httpCode"] + )); + $tenant->save(); + + if ($attempts < $maxAttempts) { + $wait = pow(2, $attempts - 1); + sleep($wait); + $ch = curl_init($provisionUrl); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-API-Key: chathub-default-key-change-me', 'Idempotency-Key: ' . $idempotencyKey], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 60, + CURLOPT_CONNECTTIMEOUT => 10 + ]); + } + } + + if ($curlError || $httpCode != 200) { + $tenant->status = 'pending'; + $tenant->save(); + Db::name('chathub_log')->insert([ + 'tenant_id' => $tenant->id, + 'action' => 'register', + 'detail' => "注册失败({$attempts}次重试): " . ($curlError ?: "HTTP $httpCode"), + 'status' => 'failed', + 'operator' => 'website', + 'createtime' => time() + ]); + $msg = '注册成功!正在为您开通,请稍候...'; + if ($isJson) { $this->_registerJson(1, $msg, $companyName, $plan, $basePrice, $totalSeats, '', $createdPassword, date('Y-m-d', strtotime($expireAt))); } + $this->_registerResultPage(true, $msg, $companyName, $plan, $basePrice, $totalSeats, '', $createdPassword); + return; + } + + $resultData = json_decode($response, true); + if ($resultData && isset($resultData['inbox_id'])) { + $tenant->status = 'active'; + $tenant->provisioned_at = date('Y-m-d H:i:s'); + $tenant->inbox_id = $resultData['inbox_id'] ?? null; + $tenant->inbox_token = $resultData['inbox_token'] ?? null; + $tenant->embed_code = $resultData['embed_code'] ?? null; + $tenant->agent_name = $resultData['inbox_name'] ?? null; + $tenant->team_id = $resultData['team_id'] ?? null; + $tenant->agent_cw_id = $resultData['agent_cw_id'] ?? null; + $tenant->agent_cw_password = $resultData['agent_cw_password'] ?? null; + $tenant->save(); + + Db::name('chathub_log')->insert([ + 'tenant_id' => $tenant->id, + 'action' => 'register', + 'detail' => "注册开通: {$companyName}, 方案: {$plan}, 席位: {$totalSeats}, 价格: ¥{$basePrice}/月" . ($attempts > 1 ? " (重试{$attempts}次后成功)" : ''), + 'status' => 'success', + 'operator' => 'website', + 'createtime' => time() + ]); + + $msg = '注册成功!AI 客服已开通,请查收邮件获取嵌入代码。'; + if ($isJson) { $this->_registerJson(1, $msg, $companyName, $plan, $basePrice, $totalSeats, $resultData['embed_code'] ?? '', $createdPassword, date('Y-m-d', strtotime($expireAt))); } + $this->_registerResultPage(true, $msg, $companyName, $plan, $basePrice, $totalSeats, $resultData['embed_code'] ?? '', $createdPassword); + return; + } + + $tenant->status = 'pending'; + $tenant->save(); + Db::name('chathub_log')->insert([ + 'tenant_id' => $tenant->id, + 'action' => 'register', + 'detail' => "注册响应无效({$attempts}次重试)", + 'status' => 'failed', + 'operator' => 'website', + 'createtime' => time() + ]); + $msg = '注册成功!正在为您开通,请稍候联系客服获取嵌入代码。'; + if ($isJson) { $this->_registerJson(1, $msg, $companyName, $plan, $basePrice, $totalSeats, '', $createdPassword, date('Y-m-d', strtotime($expireAt))); } + $this->_registerResultPage(true, $msg, $companyName, $plan, $basePrice, $totalSeats, '', $createdPassword); + } + + public function changePassword() + { + $userId = session('chathub_user_id'); + if (!$userId) { + header('Location: /addons/chathub/index/login'); + exit; + } + $error = ''; + $success = ''; + if ($this->request->isPost()) { + $old = $this->request->post('old_password'); + $new = $this->request->post('new_password'); + $confirm = $this->request->post('confirm_password'); + if (!$old || !$new || !$confirm) { + $error = '请填写所有字段'; + } elseif ($new !== $confirm) { + $error = '两次输入的新密码不一致'; + } elseif (strlen($new) < 6) { + $error = '新密码至少 6 位'; + } else { + $user = Db::name('user')->where('id', $userId)->find(); + $hashOk = !empty($user['salt']) ? (md5($old) === $user['password']) : password_verify($old, $user['password']); + if (!$hashOk) { + $error = '原密码错误'; + } else { + Db::name('user')->where('id', $userId)->update([ + 'password' => password_hash($new, PASSWORD_BCRYPT), + 'salt' => '', + 'updatetime' => time(), + ]); + $success = '密码修改成功'; + } + } + } + $cfg = get_addon_config('chathub'); + $siteName = $cfg['site_name'] ?? 'ChatHub'; + $nick = session('chathub_user_nick') ?: ''; + $msgHtml = $error ? '
'.htmlspecialchars($error).'
' : ''; + if ($success) $msgHtml .= '
'.htmlspecialchars($success).'
'; + $html = <<<'HTML' + + + + + +修改密码 - SITENAME + + + +
+ +
+
+
+

修改密码

+MSG_PLACEHOLDER +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + +HTML; + $html = str_replace('SITENAME', $siteName, $html); + $html = str_replace('MSG_PLACEHOLDER', $msgHtml, $html); + $this->render($html); + } + + public function renew() + { + $userId = session('chathub_user_id'); + if (!$userId) { + header('Location: /addons/chathub/index/login'); + exit; + } + $tenantId = (int)$this->request->get('id', 0); + $tenant = Db::name('chathub_tenant')->where('id', $tenantId)->where('user_id', $userId)->find(); + if (!$tenant) { + header('Location: /addons/chathub/index/my?error=' . urlencode('租户不存在')); + exit; + } + $config = json_decode($tenant['config'] ?? '{}', true) ?: []; + $plan = $config['plan'] ?? 'basic'; + $price = (int)($config['price'] ?? 0); + $cfg = get_addon_config('chathub'); + $alipayOn = !empty($cfg['alipay_enabled']); + $wechatOn = !empty($cfg['wechat_enabled']); + $siteName = $cfg['site_name'] ?? 'ChatHub'; + + $orderNo = 'CH' . date('YmdHis') . mt_rand(100, 999); + Db::name('chathub_order')->insert([ + 'order_no' => $orderNo, + 'tenant_id' => $tenant['id'], + 'user_id' => $userId, + 'plan' => $plan, + 'amount' => $price, + 'status' => 'pending', + 'createtime' => time(), + ]); + + $payBtns = ''; + if ($alipayOn) { + $payBtns .= '💰 支付宝支付 ¥' . $price . ''; + } + if ($wechatOn) { + $payBtns .= '💚 微信支付 ¥' . $price . ''; + } + if (!$alipayOn && !$wechatOn) { + $payBtns = '
⚠️ 支付功能未开通,请联系客服手动续费
'; + } + + $planName = ['basic' => '基础版', 'pro' => '专业版', 'enterprise' => '企业版'][$plan] ?? $plan; + $html = <<<'HTML' + + + + + +续费 - SITENAME + + + +
+

续费订阅

+

订单号:ORDERNO

+
+
租户TENANT_NAME
+
套餐PLAN_NAME
+
席位数SEATS
+
当前到期EXPIRE
+
应付金额¥PRICE /月
+
+PAY_BUTTONS +← 返回会员中心 +
+ + +HTML; + $html = str_replace('SITENAME', $siteName, $html); + $html = str_replace('ORDERNO', $orderNo, $html); + $html = str_replace('TENANT_NAME', htmlspecialchars($tenant['tenant_name']), $html); + $html = str_replace('PLAN_NAME', $planName, $html); + $html = str_replace('SEATS', $tenant['max_agents'], $html); + $html = str_replace('EXPIRE', $tenant['expire_at'] ? date('Y-m-d', strtotime($tenant['expire_at'])) : '-', $html); + $html = str_replace('PRICE', $price, $html); + $html = str_replace('PAY_BUTTONS', $payBtns, $html); + $this->render($html); + } + + private function _registerJson($code, $msg, $companyName, $plan, $price, $seats, $embedCode = '', $initialPassword = '', $expireAt = '') + { + header('Content-Type: application/json; charset=utf-8'); + exit(json_encode([ + 'code' => $code, 'msg' => $msg, + 'company_name' => $companyName, 'plan' => $plan, 'price' => $price, 'seats' => $seats, + 'embed_code' => $embedCode, 'initial_password' => $initialPassword, 'expire_at' => $expireAt, + ])); + } + + private function _registerResultPage($success, $msg, $companyName = '', $plan = '', $price = 0, $seats = 0, $embedCode = '', $initialPassword = '') + { + $backUrl = '/addons/chathub/index/landing'; + $icon = $success ? '✓' : '✗'; + $color = $success ? '#10b981' : '#ef4444'; + $planName = ['basic' => '基础版', 'pro' => '专业版', 'enterprise' => '企业版'][$plan] ?? $plan; + + echo ' + + + + +注册结果 - ChatHub + + + +
+
' . $icon . '
+

' . ($success ? '注册成功' : '注册失败') . '

+

' . htmlspecialchars($msg) . '

'; + + if ($success && $companyName) { + echo '
'; + echo '
公司名称' . htmlspecialchars($companyName) . '
'; + echo '
套餐' . htmlspecialchars($planName) . '(¥' . $price . '/月)
'; + echo '
席位' . $seats . '
'; + echo '
到期时间' . date('Y-m-d', strtotime('+30 days')) . '
'; + echo '
'; + + if ($embedCode) { + echo '

嵌入代码(复制到您的网站):

'; + echo '
' . htmlspecialchars($embedCode) . '
'; + } + + if ($initialPassword) { + echo '
'; + echo '

⚠️ 您的登录密码(请保存,登录后可修改):

'; + echo '
' . htmlspecialchars($initialPassword) . '
'; + echo '

登录邮箱:' . htmlspecialchars($companyName) . '

'; + echo '
'; + } + } + + echo '立即登录'; + echo '返回首页'; + echo '
'; + exit; + } + + public function del($ids = null) + { + $ids = $ids ?: $this->request->post('ids') ?: $this->request->param('ids'); + if ($ids) { + Db::startTrans(); + try { + $count = $this->model->where($this->model->getPk(), 'in', $ids)->delete(); + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + if ($count) $this->success('删除成功!'); + $this->error('没有删除任何记录'); + } + $this->error('参数不能为空'); + } + + public function dashboard() + { + $logs = Db::name('chathub_log')->order('createtime desc')->limit(10)->select(); + $html = ' +
+
' . $this->model->count() . '
总租户
+
' . $this->model->where("status","active")->count() . '
已开通
+
' . $this->model->where("status","pending")->count() . '
待开通
+
24/7
AI 在线
+
+

最近操作

'; + if ($logs) { + $html .= ''; + foreach ($logs as $log) { + $html .= ''; + $html .= ''; + $html .= ''; + } + $html .= '
操作状态时间
' . htmlspecialchars($log['action']) . '' . $log['status'] . '' . date('Y-m-d H:i', $log['createtime']) . '
'; + } else { + $html .= '

暂无操作记录

'; + } + $html .= '
'; + $this->render($this->layout('控制面板', $html)); + } + + public function logs() + { + $logs = Db::name('chathub_log')->order('createtime desc')->paginate(20); + $html = ' +

操作日志

'; + if ($logs->items()) { + $html .= ''; + foreach ($logs->items() as $log) { + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + } + $html .= '
租户ID操作状态操作人时间
' . $log['tenant_id'] . '' . htmlspecialchars($log['action']) . '' . $log['status'] . '' . htmlspecialchars($log['operator']) . '' . date('Y-m-d H:i:s', $log['createtime']) . '
'; + } else { + $html .= '

暂无记录

'; + } + $html .= '
'; + $this->render($this->layout('操作日志', $html)); + } + + public function suspend($ids = null) + { + $ids = $ids ?: $this->request->post('ids'); + if (!$ids) $this->error('参数不能为空'); + + $row = $this->model->get($ids); + if (!$row) $this->error('记录不存在'); + if ($row['status'] !== 'active') $this->error('只能暂停已开通的租户'); + + // 调 provision 服务暂停(它负责禁 Chatwoot inbox + 停 QwenPaw Agent) + $cfg = get_addon_config('chathub'); + $url = rtrim($cfg['provision_server_url'] ?? 'http://CoPaw:5566', '/') . '/suspend'; + $payload = json_encode([ + 'inbox_id' => $row['inbox_id'], + 'tenant_name' => $row['tenant_name'], + 'domain' => $row['domain'], + ]); + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-API-Key: chathub-default-key-change-me'], + CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30 + ]); + $response = curl_exec($ch); + $curlError = curl_error($ch); + curl_close($ch); + + if ($curlError) { + $this->error('暂停失败:无法连接 Provision 服务(' . $curlError . ')'); + } + + $resultData = json_decode($response, true); + if ($resultData && isset($resultData['success'])) { + $row->status = 'suspended'; + $row->save(); + Db::name('chathub_log')->insert([ + 'tenant_id' => $row['id'], 'action' => 'suspend', 'detail' => $response, + 'status' => 'success', 'operator' => session('admin.username') ?: 'system', 'createtime' => time() + ]); + $this->success('已暂停'); + } + + $errorMsg = $resultData['error'] ?? $response ?? '未知错误'; + $this->error('暂停失败:' . $errorMsg); + } + + public function activate($ids = null) + { + $ids = $ids ?: $this->request->post('ids'); + if (!$ids) $this->error('参数不能为空'); + + $row = $this->model->get($ids); + if (!$row) $this->error('记录不存在'); + if ($row['status'] !== 'suspended') $this->error('只能恢复已暂停的租户'); + + $cfg = get_addon_config('chathub'); + $url = rtrim($cfg['provision_server_url'] ?? 'http://CoPaw:5566', '/') . '/activate'; + $payload = json_encode(['inbox_id' => $row['inbox_id']]); + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30 + ]); + $response = curl_exec($ch); + $curlError = curl_error($ch); + curl_close($ch); + + if ($curlError) { + $this->error('恢复失败:无法连接 Provision 服务(' . $curlError . ')'); + } + + $resultData = json_decode($response, true); + if ($resultData && isset($resultData['success'])) { + $row->status = 'active'; + $row->save(); + Db::name('chathub_log')->insert([ + 'tenant_id' => $row['id'], 'action' => 'activate', 'detail' => $response, + 'status' => 'success', 'operator' => session('admin.username') ?: 'system', 'createtime' => time() + ]); + $this->success('已恢复'); + } + + $errorMsg = $resultData['error'] ?? $response ?? '未知错误'; + $this->error('恢复失败:' . $errorMsg); + } + + public function setExpire($ids = null) + { + $ids = $ids ?: $this->request->post('ids'); + $expireAt = $this->request->post('expire_at'); + if (!$ids) $this->error('参数不能为空'); + if (!$expireAt) $this->error('请输入到期时间'); + + $row = $this->model->get($ids); + if (!$row) $this->error('记录不存在'); + + $row->expire_at = $expireAt; + $row->save(); + + Db::name('chathub_log')->insert([ + 'tenant_id' => $row['id'], 'action' => 'set_expire', 'detail' => '设置到期: ' . $expireAt, + 'status' => 'success', 'operator' => session('admin.username') ?: 'system', 'createtime' => time() + ]); + + $this->success('到期时间已设置'); + } + + public function landing() + { + $cfg = get_addon_config('chathub'); + $siteName = $cfg['site_name'] ?? 'ChatHub'; + $html = <<<'HTML' + + + + + +ChatHub - AI 智能客服多租户平台 + + + +
+ +
+ +
+

AI 智能客服
多租户 SaaS 平台

+

基于 Chatwoot + QwenPaw Agent,为企业打造一站式 AI 客服解决方案。
多租户隔离、按席位计费、3 分钟开通。

+ +
+ +
+
+

核心功能

+

企业级 AI 客服所需的一切,开箱即用

+
+
+
+
🏢
+

多租户隔离

+

每个租户独立 Chatwoot 团队、收件箱、Agent,数据完全隔离,互不干扰。

+
+
+
🤖
+

AI 智能应答

+

接入 QwenPaw Agent 大模型,7×24 自动回复客户咨询,支持多轮对话和上下文理解。

+
+
+
💬
+

一键嵌入

+

提供 JS 嵌入代码,复制粘贴即可集成到任何网站,5 分钟完成上线。

+
+
+
📊
+

实时监控

+

会话量、响应时间、客户满意度等关键指标实时可见,运营决策有数。

+
+
+
💳
+

按席计费

+

基础版¥50/月起,每加一个席位+¥10,灵活扩容,用多少付多少。

+
+
+
🔒
+

数据安全

+

租户数据加密存储,HTTPS 全链路传输,符合企业级安全合规要求。

+
+
+
+ +
+
+

套餐价格

+

简单透明,按需选择,随时升级

+
+
+
+

基础版

+
¥50/月
+

适合个人或小团队

+
    +
  • 1 个管理员席位
  • +
  • 独立 Chatwoot 收件箱
  • +
  • AI 自动应答
  • +
  • 1 个网站嵌入
  • +
  • 邮件技术支持
  • +
+选择基础版 +
+ +
+

企业版

+
¥70/月 起
+

适合中大型企业

+
    +
  • 自定义席位(+¥10/席/月)
  • +
  • 独立 Chatwoot 收件箱
  • +
  • AI 自动应答
  • +
  • 不限网站嵌入
  • +
  • 7×24 专属客服
  • +
+选择企业版 +
+
+
+ +
+

3 分钟开通,立即拥有 AI 客服

+

注册即送 30 天免费试用,无需信用卡

+免费注册 +
+ + + + +HTML; + $html = str_replace('ChatHub', $siteName, $html); + $this->render($html); + } + + public function registerPage() + { + $cfg = get_addon_config('chathub'); + $siteName = $cfg['site_name'] ?? 'ChatHub'; + $plan = $this->request->get('plan', 'basic'); + if (!in_array($plan, ['basic', 'pro', 'enterprise'])) $plan = 'basic'; + $html = <<<'HTML' + + + + + +注册 ChatHub - AI 智能客服 + + + + +
+ +
+ +
+
+

注册 ChatHub 账号

+

注册后即可选择套餐,开通 AI 智能客服

+ +
+
+ +
+ + + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +返回首页 +
+
+
+ + + + +HTML; + $html = str_replace('PLAN_PLACEHOLDER', $plan, $html); + $html = str_replace('ChatHub', $siteName, $html); + $this->render($html); + } + + public function login() + { + if (session('chathub_user_id')) { + header('Location: /addons/chathub/index/my'); + exit; + } + $error = $this->request->get('error', ''); + $cfg = get_addon_config('chathub'); + $siteName = $cfg['site_name'] ?? 'ChatHub'; + $html = <<<'HTML' + + + + + +登录 - SITENAME + + + +
+
+

登录 SITENAME

+

登录后查看您的 AI 客服订阅

+ERROR_PLACEHOLDER +
+
+ + +
+
+ + +
+ +
+ +
+
+ + +HTML; + $html = str_replace('SITENAME', $siteName, $html); + $errorHtml = $error ? '
' . htmlspecialchars($error) . '
' : ''; + $html = str_replace('ERROR_PLACEHOLDER', $errorHtml, $html); + $this->render($html); + } + + public function doLogin() + { + $email = trim($this->request->post('email', '')); + $password = $this->request->post('password', ''); + + if (!$email || !$password) { + header('Location: /addons/chathub/index/login?error=' . urlencode('请填写邮箱和密码')); + exit; + } + + $user = Db::name('user')->where('email', $email)->find(); + if (!$user) { + header('Location: /addons/chathub/index/login?error=' . urlencode('邮箱或密码错误')); + exit; + } + + $hashOk = false; + if (!empty($user['salt'])) { + $hashOk = (md5($password) === $user['password']); + } else { + $hashOk = password_verify($password, $user['password']); + } + if (!$hashOk) { + header('Location: /addons/chathub/index/login?error=' . urlencode('邮箱或密码错误')); + exit; + } + + session('chathub_user_id', $user['id']); + session('chathub_user_email', $user['email']); + session('chathub_user_nick', $user['nickname'] ?: $user['username']); + + Db::name('user')->where('id', $user['id'])->update([ + 'logintime' => time(), + 'loginip' => $this->request->ip(), + 'prevtime' => $user['logintime'] ?: time(), + 'successions' => $user['successions'] + 1, + ]); + + header('Location: /addons/chathub/index/my'); + exit; + } + + public function logout() + { + session('chathub_user_id', null); + session('chathub_user_email', null); + session('chathub_user_nick', null); + header('Location: /addons/chathub/index/landing'); + exit; + } + + public function my() + { + $userId = session('chathub_user_id'); + if (!$userId) { + header('Location: /addons/chathub/index/login'); + exit; + } + + $user = Db::name('user')->where('id', $userId)->find(); + if (!$user) { + session('chathub_user_id', null); + header('Location: /addons/chathub/index/login?error=' . urlencode('账号不存在')); + exit; + } + + $tenants = Db::name('chathub_tenant')->where('user_id', $userId) + ->order('id desc')->select(); + + $cfg = get_addon_config('chathub'); + $siteName = $cfg['site_name'] ?? 'ChatHub'; + + $flash = ''; + $order = $this->request->param('order'); + if ($this->request->param('just_paid')) { + $flash = '
🎉 付款成功!您的 AI 客服已激活。如未显示嵌入代码,请点下方"嵌入代码"按钮获取。
'; + } elseif ($this->request->param('provisioning')) { + $flash = '
⚡ 正在为您开通资源(订单 ' . htmlspecialchars($order ?? '') . '),通常 30 秒内完成,请稍后刷新页面。如长时间未完成,可点"重新开通"按钮。
'; + } elseif ($this->request->param('pending')) { + $flash = '
⏳ 付款确认中(订单 ' . htmlspecialchars($order ?? '') . '),请稍候 30 秒后刷新页面。
'; + } elseif ($err = $this->request->param('error')) { + $flash = '
⚠️ ' . htmlspecialchars($err) . '
'; + } + + $rows = ''; + foreach ($tenants as $t) { + $statusBadge = ['active' => '#10b981', 'provisioning' => '#3b82f6', 'pending' => '#f59e0b', 'suspended' => '#ef4444', 'disabled' => '#6b7280']; + $statusLabel = ['active' => '运行中', 'provisioning' => '开通中', 'pending' => '待开通', 'suspended' => '已暂停', 'disabled' => '已停用']; + $color = $statusBadge[$t['status']] ?? '#6b7280'; + $label = $statusLabel[$t['status']] ?? $t['status']; + $config = json_decode($t['config'] ?? '{}', true) ?: []; + $planName = ['basic' => '基础版', 'pro' => '专业版', 'enterprise' => '企业版'][$config['plan'] ?? ''] ?? ($config['plan'] ?? '-'); + $price = $config['price'] ?? 0; + $expire = $t['expire_at'] ? date('Y-m-d', strtotime($t['expire_at'])) : '-'; + $daysLeft = $t['expire_at'] ? max(0, (strtotime($t['expire_at']) - time()) / 86400) : 0; + $daysLeftInt = (int)$daysLeft; + $embed = $t['embed_code'] ?: '暂未生成'; + $embedEsc = htmlspecialchars($embed); + $hasEmbed = !empty($t['embed_code']); + + $reprovBtn = !$hasEmbed + ? '重新开通' + : ''; + + $rows .= ' +' . htmlspecialchars($t['tenant_name']) . '
' . htmlspecialchars($t['domain']) . ' +' . $label . ' +' . $planName . '
¥' . $price . '/月 · ' . $t['max_agents'] . '席 +' . $expire . '
剩 ' . $daysLeftInt . ' 天 + +' . $reprovBtn . ' + +' . ($daysLeftInt < 7 ? '续费' : '') . ' + +'; + } + + if (empty($rows)) { + $rows = '还没有租户,立即注册'; + } + + $nick = htmlspecialchars($user['nickname'] ?: $user['username'] ?: $user['email']); + + $html = <<<'HTML' + + + + + +会员中心 - SITENAME + + + +
+ +
+ +
+
+

欢迎回来,NICK_PLACEHOLDER

+

管理您的 AI 客服订阅、查看嵌入代码、续费套餐

+
+ +FLASH_MESSAGE + +
+
TENANT_COUNT
租户总数
+
ACTIVE_COUNT
运行中
+
TOTAL_SEATS
总席位数
+
¥MONTHLY_TOTAL
月费合计
+
+ +
+

我的租户

+ + + + + +TENANT_ROWS + +
租户状态套餐到期操作
+
+ +
+

渠道管理

+

为您的客服租户绑定 Amazon / JD / Taobao / PDD / TikTok 等外部平台凭证。绑定后,AI 会在收到含商品 ASIN/SKU 的消息时,自动调用平台 API 拉取实时信息(如价格、库存),让回答更准确。

+前往渠道管理 → +
+
+ + + + + + +HTML; + + $tenantCount = count($tenants); + $activeCount = 0; + $totalSeats = 0; + $monthlyTotal = 0; + foreach ($tenants as $t) { + if ($t['status'] === 'active') $activeCount++; + $totalSeats += (int)$t['max_agents']; + $cfg_t = json_decode($t['config'] ?? '{}', true) ?: []; + $monthlyTotal += (int)($cfg_t['price'] ?? 0); + } + + $html = str_replace('SITENAME', $siteName, $html); + $html = str_replace('NICK_PLACEHOLDER', $nick, $html); + $html = str_replace('TENANT_COUNT', $tenantCount, $html); + $html = str_replace('ACTIVE_COUNT', $activeCount, $html); + $html = str_replace('TOTAL_SEATS', $totalSeats, $html); + $html = str_replace('MONTHLY_TOTAL', $monthlyTotal, $html); + $html = str_replace('TENANT_ROWS', $rows, $html); + $html = str_replace('FLASH_MESSAGE', $flash, $html); + + $this->render($html); + } + + public function channelList() + { + $userId = session('chathub_user_id'); + if (!$userId) { + header('Location: /addons/chathub/index/login'); + exit; + } + $tenant = Db::name('chathub_tenant')->where('user_id', $userId)->order('id desc')->find(); + if (!$tenant) { + header('Location: /addons/chathub/index/my?error=' . urlencode('请先开通租户')); + exit; + } + $tenantId = (int)$tenant['id']; + + $rows = Db::name('chathub_channel_account') + ->where('tenant_id', $tenantId) + ->order('id DESC') + ->select(); + + $cfg = get_addon_config('chathub'); + $siteName = $cfg['site_name'] ?? 'ChatHub'; + + $channels = ['web_widget', 'amazon', 'jd', 'taobao', 'pdd', 'tiktok']; + $byChannel = []; + foreach ($rows as $r) { + $byChannel[$r['channel']] = $r; + } + + $channelRows = ''; + foreach ($channels as $ch) { + $r = $byChannel[$ch] ?? null; + if ($r) { + $status = $r['status'] === 'active' ? '已绑定' : '已停用'; + $cls = $r['status'] === 'active' ? 'badge-green' : 'badge-gray'; + $authAt = $r['last_refresh_at'] ?: '-'; + $channelRows .= "{$ch}{$status}{$authAt}管理"; + } else { + $channelRows .= "{$ch}未配置-去绑定"; + } + } + + $html = << + + + +渠道管理 - {$siteName} + + + + +
+

渠道管理

+

管理您已开通的客服渠道凭证。Amazon / JD / Taobao / PDD 凭证在服务端加密存储,不在浏览器暴露。

+ + +{$channelRows} +
渠道状态授权时间操作
+

← 返回会员中心

+
+ + +HTML; + $this->render($html); + } + + public function channelAuth() + { + $userId = session('chathub_user_id'); + if (!$userId) { + header('Location: /addons/chathub/index/login'); + exit; + } + $tenant = Db::name('chathub_tenant')->where('user_id', $userId)->order('id desc')->find(); + if (!$tenant) { + header('Location: /addons/chathub/index/my?error=' . urlencode('请先开通租户')); + exit; + } + $tenantId = (int)$tenant['id']; + $channel = input('get.channel', 'amazon'); + $allowed = ['web_widget', 'amazon', 'jd', 'taobao', 'pdd', 'tiktok']; + if (!in_array($channel, $allowed, true)) { + header('Location: /addons/chathub/index/channelList?error=' . urlencode('不支持的渠道')); + exit; + } + + $cfg = get_addon_config('chathub'); + $siteName = $cfg['site_name'] ?? 'ChatHub'; + + if (request()->isPost()) { + if ($channel === 'amazon') { + $accessToken = trim((string)input('post.access_token', '')); + $partnerTag = trim((string)input('post.partner_tag', '')); + $marketplace = trim((string)input('post.marketplace', 'us')); + if ($accessToken === '' || $partnerTag === '') { + header('Location: /addons/chathub/index/channelAuth?channel=amazon&error=' . urlencode('Access Token 和 Partner Tag 必填')); + exit; + } + $credArr = [ + 'access_token' => $accessToken, + 'partner_tag' => $partnerTag, + 'marketplace' => in_array($marketplace, ['us','jp','de','uk','fr','it','es','ca','in','br','mx','au','sg'], true) ? $marketplace : 'us', + ]; + } else { + $appId = trim((string)input('post.app_id', '')); + $appSecret = trim((string)input('post.app_secret', '')); + $refreshToken = trim((string)input('post.refresh_token', '')); + if ($appId === '' || $appSecret === '') { + header('Location: /addons/chathub/index/channelAuth?channel=' . urlencode($channel) . '&error=' . urlencode('App ID 和 App Secret 必填')); + exit; + } + $credArr = [ + 'app_id' => $appId, + 'app_secret' => $appSecret, + 'refresh_token' => $refreshToken, + ]; + } + $credJson = json_encode($credArr, JSON_UNESCAPED_UNICODE); + + $row = Db::name('chathub_channel_account') + ->where('tenant_id', $tenantId) + ->where('channel', $channel) + ->find(); + $now = time(); + $data = [ + 'tenant_id' => $tenantId, + 'channel' => $channel, + 'credentials_encrypted' => $credJson, + 'status' => 'active', + 'updatetime' => $now, + ]; + if ($row) { + Db::name('chathub_channel_account')->where('id', $row['id'])->update($data); + } else { + $data['createtime'] = $now; + Db::name('chathub_channel_account')->insert($data); + } + header('Location: /addons/chathub/index/channelList'); + exit; + } + + $existing = Db::name('chathub_channel_account') + ->where('tenant_id', $tenantId) + ->where('channel', $channel) + ->find(); + + if ($channel === 'amazon') { + $formBody = <<<'AMAZON' +

+

+

+AMAZON; + $hint = 'Amazon PA-API 5 凭证:Access Token 是 LWA (Login with Amazon) 流程获得的 Bearer Token,Partner Tag 在 Amazon Associates 后台获取。Marketplace 决定商品数据所在站点。'; + } else { + $formBody = <<<'OTHER' +

+

+

+OTHER; + $hint = '凭证会加密保存在服务端数据库(chathub 表 fa_chathub_channel_account)。Worker 通过 GATEWAY_AES_KEY 解密后调用对应平台 API。'; + } + + $html = << + + + +绑定渠道 - {$siteName} + + + +
+

绑定 {$channel}

+
+{$formBody} +

取消

+
+

{$hint}

+
+ + +HTML; + $this->render($html); + } + + public function channelCallback() + { + header('Content-Type: text/plain; charset=utf-8'); + echo '未启用 OAuth 回调。请使用 /addons/chathub/index/channelAuth 表单直接绑定 App Secret。'; + exit; + } + + public function payAlipay() + { + $orderNo = $this->request->get('order'); + $order = Db::name('chathub_order')->where('order_no', $orderNo)->find(); + if (!$order) { exit('订单不存在'); } + $cfg = get_addon_config('chathub'); + if (empty($cfg['alipay_enabled'])) { exit('支付宝未启用'); } + if (empty($cfg['alipay_app_id']) || empty($cfg['alipay_merchant_private_key'])) { + exit('支付宝未配置完成,请在后台「插件管理 → ChatHub → 配置」填写 APPID 和私钥'); + } + Db::name('chathub_order')->where('id', $order['id'])->update(['pay_method' => 'alipay', 'updatetime' => time()]); + $gateway = !empty($cfg['alipay_sandbox']) ? 'https://openapi.alipaydev.com/gateway.do' : 'https://openapi.alipay.com/gateway.do'; + $bizContent = json_encode([ + 'out_trade_no' => $order['order_no'], + 'total_amount' => number_format($order['amount'], 2, '.', ''), + 'subject' => 'ChatHub 续费 - 订单 ' . $order['order_no'], + 'product_code' => 'FAST_INSTANT_TRADE_PAY', + ]); + $params = [ + 'app_id' => $cfg['alipay_app_id'], + 'method' => 'alipay.trade.page.pay', + 'charset' => 'utf-8', + 'sign_type' => 'RSA2', + 'timestamp' => date('Y-m-d H:i:s'), + 'version' => '1.0', + 'notify_url' => 'https://hub.275763.xyz/addons/chathub/index/payNotify/pay_method/alipay', + 'return_url' => 'https://hub.275763.xyz/addons/chathub/index/payReturn/order/' . $order['order_no'], + 'biz_content' => $bizContent, + ]; + $params['sign'] = $this->_alipaySign($params, $cfg['alipay_merchant_private_key']); + $url = $gateway . '?' . http_build_query($params); + header('Location: ' . $url); + exit; + } + + private function _alipaySign($params, $privateKey) + { + ksort($params); + $string = ''; + foreach ($params as $k => $v) { + if ($k === 'sign' || $v === '') continue; + $string .= '&' . $k . '=' . $v; + } + $string = substr($string, 1); + $res = openssl_pkey_get_private($privateKey); + openssl_sign($string, $sign, $res, OPENSSL_ALGO_SHA256); + return base64_encode($sign); + } + + public function payWechat() + { + $orderNo = $this->request->get('order'); + $order = Db::name('chathub_order')->where('order_no', $orderNo)->find(); + if (!$order) { exit('订单不存在'); } + $cfg = get_addon_config('chathub'); + if (empty($cfg['wechat_enabled'])) { exit('微信支付未启用'); } + if (empty($cfg['wechat_app_id']) || empty($cfg['wechat_mch_id']) || empty($cfg['wechat_api_v3_key'])) { + exit('微信支付未配置完成,请在后台填写 APPID/商户号/APIv3密钥'); + } + Db::name('chathub_order')->where('id', $order['id'])->update(['pay_method' => 'wechat', 'updatetime' => time()]); + $codeUrl = $this->_wechatNativePay($order, $cfg); + $cfg2 = get_addon_config('chathub'); + $siteName = $cfg2['site_name'] ?? 'ChatHub'; + $amount = number_format($order['amount'], 2); + $qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=' . urlencode($codeUrl); + $html = <<<'HTML' + + + +微信支付 - SITENAME + + + +
+

微信支付

+

订单 ORDERNO

+
二维码
+
¥AMOUNT
+

使用微信扫描二维码完成支付
支付完成后页面会自动跳转

+← 返回会员中心 +
+ + +HTML; + $html = str_replace('SITENAME', $siteName, $html); + $html = str_replace('ORDERNO', $orderNo, $html); + $html = str_replace('QRURL', $qrUrl, $html); + $html = str_replace('AMOUNT', $amount, $html); + $this->render($html); + } + + private function _wechatNativePay($order, $cfg) + { + $url = 'https://api.mch.weixin.qq.com/v3/pay/transactions/native'; + $body = json_encode([ + 'appid' => $cfg['wechat_app_id'], + 'mchid' => $cfg['wechat_mch_id'], + 'description' => 'ChatHub 续费', + 'out_trade_no' => $order['order_no'], + 'notify_url' => 'https://hub.275763.xyz/addons/chathub/index/payNotify/pay_method/wechat', + 'amount' => ['total' => (int)($order['amount'] * 100), 'currency' => 'CNY'], + ]); + $token = $this->_wechatSign('POST', preg_replace('#https?://#', '', $url), $body, $cfg); + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: WECHATTOKEN_TYPE ' . $token], + CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, + ]); + $resp = curl_exec($ch); + $data = json_decode($resp, true); + return $data['code_url'] ?? ('weixin://wxpay/bizpayurl?pr=CONFIGURE_ERROR_' . $order['order_no']); + } + + private function _wechatSign($method, $url, $body, $cfg) + { + $random = bin2hex(random_bytes(16)); + $timestamp = (string)time(); + $nonce = bin2hex(random_bytes(8)); + $message = $method . "\n" . $url . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n"; + openssl_sign('sha256', $message, $sign, $cfg['wechat_key_path'], OPENSSL_ALGO_SHA256); + $token = sprintf('mchid="%s",nonce_str="%s",timestamp="%s",serial_no="%s",signature="%s"', + $cfg['wechat_mch_id'], $nonce, $timestamp, 'PLACEHOLDER_SERIAL', base64_encode($sign)); + return $token; + } + + public function payNotify() + { + $method = $this->request->param('pay_method'); + $raw = file_get_contents('php://input'); + Db::name('chathub_log')->insert([ + 'tenant_id' => 0, 'action' => 'pay_notify_' . $method, + 'detail' => $raw, 'status' => 'success', 'operator' => 'system', 'createtime' => time(), + ]); + if ($method === 'alipay') { + $params = $this->request->post(); + $cfg = get_addon_config('chathub'); + $sign = $params['sign'] ?? ''; + unset($params['sign'], $params['sign_type']); + ksort($params); + $string = ''; + foreach ($params as $k => $v) { $string .= '&' . $k . '=' . '"' . $v . '"'; } + $string = substr($string, 1); + $res = openssl_pkey_get_public($cfg['alipay_public_key']); + $verified = openssl_verify($string, base64_decode($sign), $res, OPENSSL_ALGO_SHA256); + if ($verified === 1 && $params['trade_status'] === 'TRADE_SUCCESS') { + $this->_markOrderPaid($params['out_trade_no'], 'alipay', $params['trade_no']); + exit('success'); + } + exit('fail'); + } elseif ($method === 'wechat') { + $data = json_decode($raw, true); + if (isset($data['out_trade_no'])) { + $this->_markOrderPaid($data['out_trade_no'], 'wechat', $data['transaction_id'] ?? ''); + exit(json_encode(['code' => 'SUCCESS', 'message' => '成功'])); + } + } + exit('fail'); + } + + private function _markOrderPaid($orderNo, $method, $tradeNo) + { + $order = Db::name('chathub_order')->where('order_no', $orderNo)->find(); + if (!$order || $order['status'] === 'paid') return; + Db::name('chathub_order')->where('id', $order['id'])->update([ + 'status' => 'paid', 'pay_trade_no' => $tradeNo, + 'paid_at' => date('Y-m-d H:i:s'), 'updatetime' => time(), + ]); + $tenant = Db::name('chathub_tenant')->where('id', $order['tenant_id'])->find(); + if ($tenant) { + $newExpire = max(strtotime($tenant['expire_at'] ?: 'now'), time()); + $newExpireAt = date('Y-m-d H:i:s', $newExpire + 30 * 86400); + $logMsg = "续费成功,订单 {$orderNo},金额 ¥{$order['amount']}"; + + if (empty($tenant['embed_code'])) { + Db::name('chathub_tenant')->where('id', $tenant['id'])->update([ + 'status' => 'provisioning', + 'expire_at' => $newExpireAt, + ]); + $logMsg .= '(含资源开通)'; + $this->_provisionAsync($tenant['id']); + } else { + Db::name('chathub_tenant')->where('id', $tenant['id'])->update([ + 'status' => 'active', + 'expire_at' => $newExpireAt, + ]); + } + + Db::name('chathub_log')->insert([ + 'tenant_id' => $tenant['id'], 'action' => 'renew', + 'detail' => $logMsg, + 'status' => 'success', 'operator' => 'payment', 'createtime' => time(), + ]); + } + } + + private function _provisionAsync($tenantId) + { + $tenant = Db::name('chathub_tenant')->where('id', $tenantId)->find(); + if (!$tenant) return false; + + $cfg = get_addon_config('chathub'); + $key = 'reprov-' . $tenant['id'] . '-' . md5($tenant['domain'] . '|' . $tenant['channel_type']); + $provisionUrl = rtrim($cfg['provision_server_url'] ?? 'http://CoPaw:5566', '/') . '/provision'; + $payload = json_encode([ + 'name' => $tenant['tenant_name'], + 'domain' => $tenant['domain'], + 'email' => $tenant['email'], + 'type' => $tenant['channel_type'], + 'max_agents' => (int)$tenant['max_agents'], + ]); + + $ch = curl_init($provisionUrl); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'X-API-Key: ' . ($cfg['provision_api_key'] ?? 'chathub-default-key-change-me'), + 'Idempotency-Key: ' . $key, + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_CONNECTTIMEOUT => 5, + ]); + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + + if ($err || $httpCode != 200) { + Db::name('chathub_log')->insert([ + 'tenant_id' => $tenantId, 'action' => 'reprovision', + 'detail' => "重试 provision 失败: " . ($err ?: "HTTP $httpCode"), + 'status' => 'failed', 'operator' => 'system', 'createtime' => time(), + ]); + return false; + } + + $resultData = json_decode($response, true); + if ($resultData && isset($resultData['inbox_id'])) { + Db::name('chathub_tenant')->where('id', $tenantId)->update([ + 'status' => 'active', + 'provisioned_at' => date('Y-m-d H:i:s'), + 'inbox_id' => $resultData['inbox_id'] ?? null, + 'inbox_token' => $resultData['inbox_token'] ?? null, + 'embed_code' => $resultData['embed_code'] ?? null, + 'agent_name' => $resultData['inbox_name'] ?? null, + 'team_id' => $resultData['team_id'] ?? null, + 'agent_cw_id' => $resultData['agent_cw_id'] ?? null, + 'agent_cw_password' => $resultData['agent_cw_password'] ?? null, + ]); + Db::name('chathub_log')->insert([ + 'tenant_id' => $tenantId, 'action' => 'reprovision', + 'detail' => '补建资源成功 (inbox_id=' . ($resultData['inbox_id'] ?? '?') . ')', + 'status' => 'success', 'operator' => 'system', 'createtime' => time(), + ]); + return true; + } + + Db::name('chathub_log')->insert([ + 'tenant_id' => $tenantId, 'action' => 'reprovision', + 'detail' => '响应无效: ' . substr((string)$response, 0, 200), + 'status' => 'failed', 'operator' => 'system', 'createtime' => time(), + ]); + return false; + } + + public function reprovision() + { + $userId = session('chathub_user_id'); + if (!$userId) { + header('Location: /addons/chathub/index/login'); + exit; + } + $ids = $this->request->param('ids'); + if (!$ids) { + header('Location: /addons/chathub/index/my?error=' . urlencode('缺少租户 ID')); + exit; + } + $tenant = Db::name('chathub_tenant')->where('id', $ids) + ->where('user_id', $userId)->find(); + if (!$tenant) { + header('Location: /addons/chathub/index/my?error=' . urlencode('租户不存在或无权操作')); + exit; + } + + Db::name('chathub_tenant')->where('id', $tenant['id'])->update(['status' => 'provisioning']); + $ok = $this->_provisionAsync($tenant['id']); + $param = $ok ? 'just_paid=1' : 'provisioning=1'; + header('Location: /addons/chathub/index/my?' . $param); + exit; + } + + public function payReturn() + { + $orderNo = $this->request->param('order'); + $redirect = '/addons/chathub/index/my?order=' . urlencode($orderNo); + $order = Db::name('chathub_order')->where('order_no', $orderNo)->find(); + if ($order) { + $tenant = Db::name('chathub_tenant')->where('id', $order['tenant_id'])->find(); + if ($order['status'] !== 'paid') { + $redirect .= '&pending=1'; + } elseif ($tenant && empty($tenant['embed_code'])) { + $redirect .= '&provisioning=1'; + } else { + $redirect .= '&just_paid=1'; + } + } + header('Location: ' . $redirect); + exit; + } +} +// touched at Fri Jun 5 10:07:55 AM CST 2026 diff --git a/fastadmin/chathub/info.ini b/fastadmin/chathub/info.ini new file mode 100644 index 0000000..f36ebea --- /dev/null +++ b/fastadmin/chathub/info.ini @@ -0,0 +1,7 @@ +name = chathub +title = ChatHub 租户管理 +intro = Chatwoot AI客服系统租户管理平台,支持多租户开通、配置管理、状态监控 +author = GreatQiu +website = https://github.com/hanmolabiqiu/chatwoot-ai-agent +version = 1.0.0 +state = 1 diff --git a/fastadmin/chathub/install.sql b/fastadmin/chathub/install.sql new file mode 100644 index 0000000..ac18aa3 --- /dev/null +++ b/fastadmin/chathub/install.sql @@ -0,0 +1,98 @@ +CREATE TABLE IF NOT EXISTS `__PREFIX__chathub_tenant` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', + `user_id` int(11) unsigned DEFAULT NULL COMMENT 'fa_user.id (创建者)', + `tenant_name` varchar(100) NOT NULL DEFAULT '' COMMENT '租户名称', + `domain` varchar(255) NOT NULL DEFAULT '' COMMENT '域名', + `email` varchar(255) NOT NULL DEFAULT '' COMMENT 'Chatwoot登录邮箱', + `inbox_id` int(11) DEFAULT NULL COMMENT 'Chatwoot Inbox ID', + `inbox_token` varchar(100) DEFAULT NULL COMMENT 'Chatwoot Inbox Token', + `team_id` int(11) DEFAULT NULL COMMENT 'Chatwoot Team ID', + `agent_id` varchar(100) DEFAULT NULL COMMENT 'QwenPaw Agent ID', + `agent_cw_id` int(11) DEFAULT NULL COMMENT 'Chatwoot Agent用户ID', + `agent_cw_password` varchar(100) DEFAULT NULL COMMENT 'Chatwoot初始密码', + `agent_name` varchar(100) DEFAULT NULL COMMENT 'QwenPaw Agent 名称', + `max_agents` int(11) NOT NULL DEFAULT 3 COMMENT '最大坐席数', + `channel_type` enum('web_widget','api','amazon','jd','taobao','pdd','tiktok') NOT NULL DEFAULT 'web_widget' COMMENT '通道类型', + `status` enum('pending','provisioning','active','suspended','disabled') NOT NULL DEFAULT 'pending' COMMENT '状态', + `config` text COMMENT '配置JSON', + `embed_code` text COMMENT '嵌入代码', + `api_credentials` text COMMENT 'API凭据JSON', + `provisioned_at` datetime DEFAULT NULL COMMENT '开通时间', + `expire_at` datetime DEFAULT NULL COMMENT '到期时间', + `last_active_at` datetime DEFAULT NULL COMMENT '最后活跃时间', + `createtime` int(10) DEFAULT NULL COMMENT '创建时间', + `updatetime` int(10) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_domain` (`domain`), + KEY `idx_status` (`status`), + KEY `idx_inbox_id` (`inbox_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ChatHub租户表'; + +CREATE TABLE IF NOT EXISTS `__PREFIX__chathub_log` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', + `tenant_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '租户ID (0=系统级)', + `action` varchar(50) NOT NULL DEFAULT '' COMMENT '操作类型 (register/renew/pay_notify_*/reprovision/...)', + `detail` text COMMENT '操作详情', + `status` enum('success','failed') NOT NULL DEFAULT 'success' COMMENT '状态', + `operator` varchar(100) DEFAULT NULL COMMENT '操作人 (用户邮箱或system)', + `createtime` int(10) DEFAULT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_tenant_id` (`tenant_id`), + KEY `idx_action` (`action`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ChatHub操作日志表'; + +CREATE TABLE IF NOT EXISTS `__PREFIX__chathub_order` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', + `order_no` varchar(50) NOT NULL COMMENT '订单号', + `tenant_id` int(11) unsigned NOT NULL COMMENT '租户ID', + `user_id` int(11) unsigned NOT NULL COMMENT '下单用户ID', + `plan` varchar(20) NOT NULL COMMENT '方案 (basic/pro/enterprise)', + `amount` decimal(10,2) NOT NULL COMMENT '金额', + `pay_method` varchar(20) DEFAULT NULL COMMENT '支付方式 (alipay/wechat)', + `pay_trade_no` varchar(100) DEFAULT NULL COMMENT '支付平台交易号', + `status` enum('pending','paid','failed','refunded') NOT NULL DEFAULT 'pending' COMMENT '状态', + `paid_at` datetime DEFAULT NULL COMMENT '支付完成时间', + `expire_at` datetime DEFAULT NULL COMMENT '续期到期时间', + `createtime` int(10) unsigned NOT NULL COMMENT '创建时间', + `updatetime` int(10) unsigned DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_order_no` (`order_no`), + KEY `idx_tenant_id` (`tenant_id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ChatHub订单表'; + +CREATE TABLE IF NOT EXISTS `__PREFIX__chathub_channel_account` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', + `tenant_id` int(11) unsigned NOT NULL COMMENT '租户ID', + `channel` enum('amazon','jd','taobao','pdd','tiktok') NOT NULL COMMENT '平台', + `shop_id` varchar(100) DEFAULT NULL COMMENT '店铺ID', + `shop_name` varchar(200) DEFAULT NULL COMMENT '店铺名称', + `credentials_encrypted` varbinary(2048) NOT NULL COMMENT 'AES-256-GCM 加密凭据', + `expires_at` datetime DEFAULT NULL COMMENT 'Access Token 过期时间', + `status` enum('active','expired','error','paused') DEFAULT 'active' COMMENT '状态', + `last_error` text COMMENT '最后一次错误', + `last_refresh_at` datetime DEFAULT NULL COMMENT '上次刷新时间', + `rate_limit_per_sec` int(10) unsigned DEFAULT 5 COMMENT '每秒请求上限', + `createtime` int(10) DEFAULT NULL COMMENT '创建时间', + `updatetime` int(10) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_tenant_id` (`tenant_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ChatHub渠道账号表'; + +CREATE TABLE IF NOT EXISTS `__PREFIX__chathub_gateway_log` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', + `tenant_id` int(11) unsigned NOT NULL COMMENT '租户ID', + `channel` enum('amazon','jd','taobao','pdd','tiktok') NOT NULL COMMENT '平台', + `query_hash` char(64) NOT NULL COMMENT '查询内容 SHA256', + `status` enum('success','cache_hit','rate_limited','breaker_open','error','timeout','no_creds') NOT NULL COMMENT '调用结果', + `latency_ms` int(10) unsigned DEFAULT NULL COMMENT '延迟(毫秒)', + `error_msg` varchar(500) DEFAULT NULL COMMENT '错误信息', + `createtime` int(10) DEFAULT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_tenant_id` (`tenant_id`), + KEY `idx_channel_status` (`channel`,`status`), + KEY `idx_createtime` (`createtime`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ChatHub Gateway调用日志'; diff --git a/fastadmin/chathub/model/ChathubTenant.php b/fastadmin/chathub/model/ChathubTenant.php new file mode 100644 index 0000000..5a3f0be --- /dev/null +++ b/fastadmin/chathub/model/ChathubTenant.php @@ -0,0 +1,98 @@ + '待开通', 'active' => '正常', 'suspended' => '已暂停', 'disabled' => '已禁用']; + return $status[$data['status']] ?? ''; + } + + /** + * 通道类型文本 + */ + public function getChannelTypeTextAttr($value, $data) + { + $types = ['web_widget' => '网页组件', 'api' => 'API接口']; + return $types[$data['channel_type']] ?? ''; + } + + /** + * 配置JSON解析 + */ + public function getConfigAttr($value) + { + return $value ? json_decode($value, true) : []; + } + + public function setConfigAttr($value) + { + return json_encode($value); + } + + /** + * API凭据JSON解析 + */ + public function getApiCredentialsAttr($value) + { + return $value ? json_decode($value, true) : []; + } + + public function setApiCredentialsAttr($value) + { + return json_encode($value); + } + + /** + * 团队ID文本 + */ + public function getTeamIdTextAttr($value, $data) + { + if (empty($data['team_id'])) { + return '-'; + } + return '#' . $data['team_id']; + } + + /** + * 邮箱文本(脱敏显示) + */ + public function getEmailTextAttr($value, $data) + { + if (empty($data['email'])) { + return '-'; + } + $parts = explode('@', $data['email']); + if (count($parts) === 2) { + $name = $parts[0]; + $len = strlen($name); + $masked = substr($name, 0, min(2, $len)) . str_repeat('*', max(0, $len - 2)); + return $masked . '@' . $parts[1]; + } + return $data['email']; + } +}