diff --git a/api/auth/csrf.php b/api/auth/csrf.php new file mode 100644 index 0000000..193f55c --- /dev/null +++ b/api/auth/csrf.php @@ -0,0 +1,10 @@ + true]); + +Response::success(['csrf_token' => csrfToken()]); diff --git a/api/auth/forgot.php b/api/auth/forgot.php index 88bc894..8ec5b26 100644 --- a/api/auth/forgot.php +++ b/api/auth/forgot.php @@ -3,14 +3,11 @@ * 找回密码接口 POST /api/auth/forgot.php * 入参:username / contact_email * 说明:占位实现 —— 校验账号存在后返回成功(后续可接入 SMTP 发送重置邮件/短信验证码)。 + * 批次1:经 Api::boot(['public' => true]) 免登录,但写请求仍校验 Ajax + CSRF。 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; -if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - Response::error('请求方式错误', 400); -} +Api::boot(['public' => true]); $username = trim($_POST['username'] ?? ''); $contactEmail = trim($_POST['contact_email'] ?? ''); diff --git a/api/auth/login.php b/api/auth/login.php index 56cd108..690e7db 100644 --- a/api/auth/login.php +++ b/api/auth/login.php @@ -3,18 +3,21 @@ * 登录接口 POST /api/auth/login.php * 入参:username / password / slider_token(滑块验证通过后的token) * 出参(成功):{ code:0, msg:'登录成功', data:{ real_name, role_name, permissions } } + * + * 批次1 安全加固(REV-1/REV-8): + * - 密码 bcrypt(password_hash)优先校验;存量 sha1 用户登录命中后平滑升级 + * - 登录限流(15分钟内同 IP+账号失败≥5 次锁定) + * - 登录成功后 session_regenerate_id 防会话固定 + * - 经 Api::boot 统一走 CSRF + 安全 Session */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; -if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - Response::error('请求方式错误', 400); -} +$pdo = Api::boot(['public' => true]); $username = trim($_POST['username'] ?? ''); $password = (string)($_POST['password'] ?? ''); $sliderToken = trim($_POST['slider_token'] ?? ''); +$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; // 滑块验证:前端 slider.js 拖动通过后生成的 token,服务端校验非空且格式合法 if ($sliderToken === '' || strlen($sliderToken) < 10) { @@ -24,7 +27,9 @@ if ($username === '' || $password === '') { Response::error('请输入账号和密码'); } -$pdo = DB::getInstance()->getPdo(); +// 登录限流(fail-open) +checkLoginThrottle($username, $ip); + $stmt = $pdo->prepare( "SELECT u.*, r.role_name, r.permissions FROM system_users u @@ -34,22 +39,35 @@ $stmt = $pdo->prepare( $stmt->execute([$username]); $user = $stmt->fetch(); -if (!$user || !hash_equals($user['password'], sha1($password))) { +// 密码校验:bcrypt 优先;旧版无盐 sha1 校验通过后即时平滑升级为 bcrypt +$authOk = false; +if ($user) { + if (password_verify($password, (string)$user['password'])) { + $authOk = true; + } elseif (hash_equals((string)$user['password'], sha1($password))) { + $authOk = true; + $pdo->prepare("UPDATE system_users SET password = ? WHERE id = ?") + ->execute([password_hash($password, PASSWORD_DEFAULT), (int)$user['id']]); + } +} +if (!$authOk) { + recordLoginAttempt($username, $ip, false); Response::error('账号或密码错误'); } if ((int)$user['is_active'] !== 1) { + recordLoginAttempt($username, $ip, false); Response::error('账号已被禁用,请联系管理员'); } +recordLoginAttempt($username, $ip, true); + // 更新最后登录时间/IP -$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; $upd = $pdo->prepare("UPDATE system_users SET last_login_time = NOW(), last_login_ip = ? WHERE id = ?"); $upd->execute([$ip, $user['id']]); -// 写入 Session -if (session_status() === PHP_SESSION_NONE) { - session_start(); -} +// 会话固定防护:登录成功后重新生成 session id(保留已有 CSRF Token) +session_regenerate_id(true); + $_SESSION['user_id'] = (int)$user['id']; $_SESSION['username'] = $user['username']; $_SESSION['real_name'] = $user['real_name'] ?? $user['username']; @@ -64,4 +82,5 @@ Response::success([ 'real_name' => $_SESSION['real_name'], 'role_name' => $_SESSION['role_name'], 'permissions' => $_SESSION['permissions'], + 'csrf_token' => csrfToken(), ], '登录成功'); diff --git a/api/auth/logout.php b/api/auth/logout.php index 64db07f..610aba9 100644 --- a/api/auth/logout.php +++ b/api/auth/logout.php @@ -1,15 +1,11 @@ getPdo(); $stmt = $pdo->prepare("SELECT last_login_time, last_login_ip FROM system_users WHERE id = ?"); @@ -19,6 +18,7 @@ Response::success([ 'real_name' => $_SESSION['real_name'], 'role_name' => $_SESSION['role_name'], 'permissions' => $_SESSION['permissions'], + 'csrf_token' => csrfToken(), 'last_login_time' => $loginInfo['last_login_time'] ?? null, 'last_login_ip' => $loginInfo['last_login_ip'] ?? null, ]); diff --git a/api/channel/analysis.php b/api/channel/analysis.php index 611714c..0f531df 100644 --- a/api/channel/analysis.php +++ b/api/channel/analysis.php @@ -7,11 +7,9 @@ * - year 为空 = 全部年份;否则按创建年份过滤 * 返回:{ years, company: [{channel, count, percent, details:[{source,count}]}], person: [...] } */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; -checkPermission('channel'); +Api::boot(['module' => 'channel']); $year = trim($_REQUEST['year'] ?? ''); if ($year !== '' && (!ctype_digit($year) || (int)$year < 2000 || (int)$year > 2100)) { @@ -32,6 +30,10 @@ $years = $pdo->query( /** 单维度渠道分析 */ function channelAnalysis($pdo, $table, $year) { + // REV-9:表名白名单,杜绝 SQL 插值 + if (!in_array($table, ['companies', 'persons'], true)) { + Response::error('参数错误', 400); + } $where = 'is_active = 1 AND source_channel IS NOT NULL AND source_channel <> \'\''; $params = []; if ($year !== '') { diff --git a/api/channel/plan_delete.php b/api/channel/plan_delete.php index c06a8b6..deee788 100644 --- a/api/channel/plan_delete.php +++ b/api/channel/plan_delete.php @@ -3,13 +3,9 @@ * 渠道新增计划删除接口 POST /api/channel/plan_delete.php * 入参:id(必填);软删除(is_active = 0) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'channel']); -checkAjax(); -checkPermission('channel'); $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { diff --git a/api/channel/plan_list.php b/api/channel/plan_list.php index dd8829f..5e56a29 100644 --- a/api/channel/plan_list.php +++ b/api/channel/plan_list.php @@ -5,11 +5,9 @@ * 参数:page / limit / channel_type / status / keyword * 返回:{ list, total, page, limit }(list 含 remaining_days 剩余天数) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'channel']); -checkPermission('channel'); [$page, $limit] = pageParams(); $channelType = trim($_REQUEST['channel_type'] ?? ''); diff --git a/api/channel/plan_save.php b/api/channel/plan_save.php index 4a92cb2..b535f9a 100644 --- a/api/channel/plan_save.php +++ b/api/channel/plan_save.php @@ -5,14 +5,9 @@ * source_detail / occurrence_address(发生地址)/ industry / start_date / end_date / remark / status(待启动|已执行|错过)/ cost(费用(元)) * 说明:编辑(传 id)时仅更新提交的字段,便于列表行内实时改状态/费用。 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/helpers.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'channel']); -checkAjax(); -checkPermission('channel'); $id = (int)($_POST['id'] ?? 0); diff --git a/api/common/Api.php b/api/common/Api.php new file mode 100644 index 0000000..768e0a4 --- /dev/null +++ b/api/common/Api.php @@ -0,0 +1,85 @@ + 'preliminary']); // 登录 + 模块权限(写接口另含 Ajax/CSRF) + * $pdo = Api::boot(); // 仅要求登录(如 session.php) + * $pdo = Api::boot(['super' => true]); // 仅超级管理员 + * $pdo = Api::boot(['permissions' => [...]]); // 任一权限 + * $pdo = Api::boot(['public' => true]); // 免登录(仅 login/forgot/csrf) + * + * boot() 统一执行: + * - 安全 Session(httponly/samesite) + * - POST/PUT/PATCH/DELETE:checkAjax(X-Requested-With)+ checkCsrf(Token 头) + * - 登录态 + 权限 + * - 返回 PDO(后续代码可直接用 $pdo;原 `$pdo = DB::getInstance()->getPdo();` + * 属冗余无害,可保留) + * + * 本文件另注册全局异常处理器:未捕获异常统一转 JSON 500 + 技术日志。 + */ +require_once __DIR__ . '/db.php'; +require_once __DIR__ . '/response.php'; +require_once __DIR__ . '/logger.php'; +require_once __DIR__ . '/security.php'; +require_once __DIR__ . '/auth.php'; +require_once __DIR__ . '/helpers.php'; +require_once __DIR__ . '/validate.php'; +require_once __DIR__ . '/duplicate_check.php'; +require_once __DIR__ . '/completeness.php'; + +/** 统一异常处理:未捕获异常 → JSON 500 + 技术日志(REV-ARCH-3) */ +set_exception_handler(function (\Throwable $e) { + logError('未捕获异常', [ + 'msg' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'uri' => $_SERVER['REQUEST_URI'] ?? '', + ]); + if (!headers_sent()) { + http_response_code(500); + header('Content-Type: application/json; charset=utf-8'); + } + echo json_encode(['code' => 500, 'msg' => '服务器内部错误,请稍后重试'], JSON_UNESCAPED_UNICODE); + exit; +}); + +class Api +{ + /** + * 统一引导 + * @param array $opts 见文件头注释 + * @return PDO + */ + public static function boot(array $opts = []) + { + startSessionSecure(); + + $isWrite = in_array($_SERVER['REQUEST_METHOD'] ?? 'GET', ['POST', 'PUT', 'PATCH', 'DELETE'], true); + if ($isWrite) { + checkAjax(); + } + + if (empty($opts['public'])) { + if (!empty($opts['super'])) { + requireSuperAdmin(); + } elseif (!empty($opts['module'])) { + checkPermission($opts['module']); + } elseif (!empty($opts['permissions'])) { + checkAnyPermission($opts['permissions']); + } else { + requireLogin(); + } + } + + if ($isWrite) { + checkCsrf(); + } + + return DB::getInstance()->getPdo(); + } +} diff --git a/api/common/auth.php b/api/common/auth.php index a0d71cc..5a7c8ce 100644 --- a/api/common/auth.php +++ b/api/common/auth.php @@ -6,13 +6,15 @@ */ require_once __DIR__ . '/db.php'; require_once __DIR__ . '/response.php'; +require_once __DIR__ . '/security.php'; -/** 启动 Session */ +/** + * 启动 Session(批次1 起经 security.php 加固:httponly + samesite=Lax + HTTPS secure) + * 兼容既有调用方式,行为不变,仅 cookie 属性增强(REV-SEC-4/REV-8) + */ function startSession() { - if (session_status() === PHP_SESSION_NONE) { - session_start(); - } + startSessionSecure(); } /** 校验写类接口的 Ajax 头 */ diff --git a/api/common/dicts.php b/api/common/dicts.php index 0f3b7ea..558072e 100644 --- a/api/common/dicts.php +++ b/api/common/dicts.php @@ -5,11 +5,9 @@ * 返回各字典表启用中的数据,供前端下拉/联动使用。 * 数据来源:官方 Excel 生成的种子数据(见 sql/dict_seed.sql),不依赖任何第三方接口。 */ -require_once __DIR__ . '/db.php'; -require_once __DIR__ . '/response.php'; -require_once __DIR__ . '/auth.php'; +require_once __DIR__ . '/Api.php'; +$pdo = Api::boot(); -requireLogin(); $type = trim($_REQUEST['type'] ?? 'all'); $pdo = DB::getInstance()->getPdo(); diff --git a/api/common/logger.php b/api/common/logger.php index 8884002..6e12e50 100644 --- a/api/common/logger.php +++ b/api/common/logger.php @@ -55,3 +55,21 @@ function logCurrent($action, $module, $targetTable = null, $targetId = null, $co $content ); } + +/** + * 技术异常日志(批次1 REV-ARCH-3):写入 runtime/logs/error-YYYYMM.log + * 业务审计走 system_logs(logAction/logCurrent),技术异常走此文件。 + * @param string $message + * @param mixed $context 上下文数组,自动 JSON 编码 + */ +function logError($message, $context = null) +{ + $dir = __DIR__ . '/../../runtime/logs'; + if (!is_dir($dir)) { + @mkdir($dir, 0775, true); + } + $line = '[' . date('Y-m-d H:i:s') . '] ' . $message + . ($context !== null ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : '') + . PHP_EOL; + @file_put_contents($dir . '/error-' . date('Ym') . '.log', $line, FILE_APPEND); +} diff --git a/api/common/phone_geo.php b/api/common/phone_geo.php index 8a4a996..36f46d5 100644 --- a/api/common/phone_geo.php +++ b/api/common/phone_geo.php @@ -11,15 +11,13 @@ * 返回:{ country, province, city, label, found } * label 示例:"中国-广东省-深圳市" / "中国香港" / "美国" */ -require_once __DIR__ . '/db.php'; -require_once __DIR__ . '/response.php'; -require_once __DIR__ . '/auth.php'; +require_once __DIR__ . '/Api.php'; +$pdo = Api::boot(); /** * 接口入口:仅当作为 HTTP 接口直接访问时执行(便于函数被 require 复用/测试) */ if (isset($_SERVER['SCRIPT_FILENAME']) && realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) { - requireLogin(); $phone = trim($_REQUEST['phone'] ?? ''); if ($phone === '') { diff --git a/api/common/security.php b/api/common/security.php new file mode 100644 index 0000000..2a0dbe8 --- /dev/null +++ b/api/common/security.php @@ -0,0 +1,84 @@ + 0, + 'path' => '/', + 'domain' => '', + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax', + ]); + session_start(); + } +} + +/** 获取(或创建)当前会话绑定的 CSRF Token */ +function csrfToken() +{ + startSessionSecure(); + if (empty($_SESSION['csrf_token']) || strlen($_SESSION['csrf_token']) !== 64) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + return $_SESSION['csrf_token']; +} + +/** + * 校验写接口的 CSRF Token(要求 X-CSRF-Token 请求头与会话一致) + * 配合前端 common.js httpPost 统一附带;防跨站请求伪造(REV-2/REV-SEC-2) + */ +function checkCsrf() +{ + startSessionSecure(); + $token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + if ($token === '' || empty($_SESSION['csrf_token']) + || !hash_equals($_SESSION['csrf_token'], $token)) { + Response::error('安全校验失败(CSRF),请刷新页面后重试', 403); + } +} + +/** + * 登录限流:同一 IP+账号 15 分钟内失败 ≥5 次则锁定(429) + * fail-open:DB 异常时跳过限流,保证登录可用(REV-8) + */ +function checkLoginThrottle($username, $ip) +{ + try { + $pdo = DB::getInstance()->getPdo(); + $pdo->prepare("DELETE FROM system_login_attempts WHERE attempt_time < NOW() - INTERVAL 30 MINUTE")->execute(); + $stmt = $pdo->prepare( + "SELECT COUNT(*) FROM system_login_attempts + WHERE ip = ? AND username = ? AND success = 0 AND attempt_time > NOW() - INTERVAL 15 MINUTE" + ); + $stmt->execute([$ip, $username]); + if ((int)$stmt->fetchColumn() >= 5) { + Response::error('尝试次数过多,请15分钟后再试', 429); + } + } catch (Exception $e) { + logError('登录限流检查失败(已跳过限流)', ['msg' => $e->getMessage()]); + } +} + +/** 记录一次登录尝试结果(供限流统计) */ +function recordLoginAttempt($username, $ip, $success) +{ + try { + $pdo = DB::getInstance()->getPdo(); + $pdo->prepare( + "INSERT INTO system_login_attempts (username, ip, success, attempt_time) VALUES (?, ?, ?, NOW())" + )->execute([$username, $ip, $success ? 1 : 0]); + } catch (Exception $e) { + logError('登录尝试记录失败', ['msg' => $e->getMessage()]); + } +} diff --git a/api/company/add.php b/api/company/add.php index 6d81d3a..a31dfa8 100644 --- a/api/company/add.php +++ b/api/company/add.php @@ -3,16 +3,9 @@ * 企业新增接口 POST /api/company/add.php * 必填:display_name;其余字段见 COMPANY_FIELDS 白名单 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; -require_once __DIR__ . '/../common/helpers.php'; -require_once __DIR__ . '/../common/completeness.php'; -require_once __DIR__ . '/../common/duplicate_check.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'company']); -checkAjax(); -checkPermission('company'); $data = extractFields(COMPANY_FIELDS); // 显示名称不再单独录入:取中文名称(数据库 display_name 为 NOT NULL) diff --git a/api/company/cert_types.php b/api/company/cert_types.php index 62985c6..ff5f531 100644 --- a/api/company/cert_types.php +++ b/api/company/cert_types.php @@ -4,11 +4,9 @@ * 返回启用中的认证类型(如:瞪羚企业、国家高新技术企业等),供编辑弹窗下拉使用 * 数据源:data_dict_certificate(原 certification_types,v1.0.16 改名,v1.0.18 再改名 data_dict_certificate) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'company']); -checkPermission('company'); $pdo = DB::getInstance()->getPdo(); $types = $pdo->query("SELECT id, name FROM data_dict_certificate WHERE is_active = 1 ORDER BY sort_order, id")->fetchAll(); diff --git a/api/company/delete.php b/api/company/delete.php index 598e71d..8172cb3 100644 --- a/api/company/delete.php +++ b/api/company/delete.php @@ -3,13 +3,9 @@ * 企业删除接口(软删除) POST /api/company/delete.php * 入参:id(或 ids 逗号分隔批量) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'company']); -checkAjax(); -checkPermission('company'); $id = (int)($_POST['id'] ?? 0); $ids = trim($_POST['ids'] ?? ''); diff --git a/api/company/detail.php b/api/company/detail.php index 8cbdaee..e911739 100644 --- a/api/company/detail.php +++ b/api/company/detail.php @@ -3,11 +3,9 @@ * 企业详情接口 GET /api/company/detail.php?id=1 * 返回企业主表信息 + 关联数据(产品品类/文档/财务/资质/联系方式) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'company']); -checkPermission('company'); $id = (int)($_REQUEST['id'] ?? 0); if ($id <= 0) { diff --git a/api/company/employees.php b/api/company/employees.php index ad1b02f..8a3ac37 100644 --- a/api/company/employees.php +++ b/api/company/employees.php @@ -4,11 +4,9 @@ * 入参:company_id(必填)/ page / limit / keyword(姓名)/ job_level(职级) * 数据源:persons JOIN person_work_experiences(按公司),联系方式取 social_accounts */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'company']); -checkPermission('company'); $companyId = (int)($_REQUEST['company_id'] ?? 0); if ($companyId <= 0) { diff --git a/api/company/export.php b/api/company/export.php index 8c18382..e96b7cd 100644 --- a/api/company/export.php +++ b/api/company/export.php @@ -3,12 +3,9 @@ * 企业导出接口(CSV) GET/POST /api/company/export.php * 入参:ids(必填,勾选的企业ID,逗号分隔)——只导出勾选的企业 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'company']); -checkPermission('company'); // 仅导出勾选的企业 $idsRaw = trim($_REQUEST['ids'] ?? ''); diff --git a/api/company/import.php b/api/company/import.php index 40b5fb5..d83422d 100644 --- a/api/company/import.php +++ b/api/company/import.php @@ -6,14 +6,9 @@ * registered_capital,established_date,is_listed,stock_code * 仅 display_name 为必填。 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; -require_once __DIR__ . '/../common/completeness.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'company']); -checkAjax(); -checkPermission('company'); if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { Response::error('请选择要上传的CSV文件', 400); diff --git a/api/company/list.php b/api/company/list.php index f803932..e3930e8 100644 --- a/api/company/list.php +++ b/api/company/list.php @@ -8,11 +8,9 @@ * 参数:page / limit / keyword / field / industry / company_type / is_active * 返回:{ list, total, page, limit } */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'company']); -checkPermission('company'); [$page, $limit] = pageParams(); $keyword = trim($_REQUEST['keyword'] ?? ''); diff --git a/api/company/official_media_add.php b/api/company/official_media_add.php index 17982c1..13112e0 100644 --- a/api/company/official_media_add.php +++ b/api/company/official_media_add.php @@ -5,16 +5,9 @@ * 入参:company_id(必填)/ platform(平台)/ account_id(账号名称)/ profile_url(主页链接)/ * remark / is_active(1启用 0停用)/ is_defult(1常用) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/helpers.php'; -require_once __DIR__ . '/../common/logger.php'; -require_once __DIR__ . '/../common/completeness.php'; -require_once __DIR__ . '/../common/duplicate_check.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'company']); -checkAjax(); -checkPermission('company'); $companyId = (int)($_POST['company_id'] ?? 0); $platform = trim($_POST['platform'] ?? ''); diff --git a/api/company/official_media_list.php b/api/company/official_media_list.php index 0c939b3..b03f9fa 100644 --- a/api/company/official_media_list.php +++ b/api/company/official_media_list.php @@ -5,11 +5,9 @@ * 字段:平台/账号名称/ID/主页链接/状态/更新日期 * 入参:company_id(必填)/ page / limit */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'company']); -checkPermission('company'); $companyId = (int)($_REQUEST['company_id'] ?? 0); if ($companyId <= 0) { diff --git a/api/company/product_add.php b/api/company/product_add.php index 877704c..a826c46 100644 --- a/api/company/product_add.php +++ b/api/company/product_add.php @@ -5,14 +5,9 @@ * is_active(1在产 0停产)/ attrs(可选 JSON:[{attr_id, value}],EAV 参数值) * 说明:attr_id 来自 company_products_attr(按企业所属行业定义的参数属性) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/helpers.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'company']); -checkAjax(); -checkPermission('company'); $companyId = (int)($_POST['company_id'] ?? 0); $categoryName = trim($_POST['category_name'] ?? ''); diff --git a/api/company/product_attrs.php b/api/company/product_attrs.php index 7f5124e..28d3904 100644 --- a/api/company/product_attrs.php +++ b/api/company/product_attrs.php @@ -3,11 +3,9 @@ * 产品参数属性定义接口 GET /api/company/product_attrs.php?industry=汽车制造 * 返回指定行业的参数属性定义(EAV),供新增产品弹窗动态生成参数输入项 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'company']); -checkPermission('company'); $industry = trim($_REQUEST['industry'] ?? ''); if ($industry === '') { diff --git a/api/company/products.php b/api/company/products.php index e6552b9..3f55199 100644 --- a/api/company/products.php +++ b/api/company/products.php @@ -4,11 +4,9 @@ * v1.0.18:返回产品基础字段(产品品类/基本定位/包含系列/状态/更新日期)+ EAV 参数值(JSON) * 入参:company_id(必填)/ page / limit */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'company']); -checkPermission('company'); $companyId = (int)($_REQUEST['company_id'] ?? 0); if ($companyId <= 0) { diff --git a/api/company/update.php b/api/company/update.php index 221adbc..fa39212 100644 --- a/api/company/update.php +++ b/api/company/update.php @@ -3,16 +3,9 @@ * 企业编辑接口 POST /api/company/update.php * 入参:id + 需要更新的白名单字段 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; -require_once __DIR__ . '/../common/helpers.php'; -require_once __DIR__ . '/../common/completeness.php'; -require_once __DIR__ . '/../common/duplicate_check.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'company']); -checkAjax(); -checkPermission('company'); $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { diff --git a/api/dashboard/stats.php b/api/dashboard/stats.php index 0b179dc..2d9367c 100644 --- a/api/dashboard/stats.php +++ b/api/dashboard/stats.php @@ -4,11 +4,9 @@ * 返回:8 个指标卡 + 区域分布(国外=世界地图按国家 / 国内=中国地图按省份,企业/人员双维度) * + 行业分布(企业/人员)+ 需求关键词(简化) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'dashboard']); -checkPermission('dashboard'); $pdo = DB::getInstance()->getPdo(); diff --git a/api/document/delete.php b/api/document/delete.php index 80fbb32..037d983 100644 --- a/api/document/delete.php +++ b/api/document/delete.php @@ -3,13 +3,9 @@ * 文件删除接口(同时删除物理文件) POST /api/document/delete.php * 入参:id(或 ids 逗号分隔批量) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['permissions' => ['document', 'competitor_data']]); -checkAjax(); -checkAnyPermission(['document', 'competitor_data']); $id = (int)($_POST['id'] ?? 0); $ids = trim($_POST['ids'] ?? ''); diff --git a/api/document/list.php b/api/document/list.php index 1bfcac4..86fef3f 100644 --- a/api/document/list.php +++ b/api/document/list.php @@ -4,11 +4,9 @@ * 参数:page / limit / keyword / file_type / is_active * 数据源:company_documents 表 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['permissions' => ['document', 'competitor_data']]); -checkAnyPermission(['document', 'competitor_data']); [$page, $limit] = pageParams(); $keyword = trim($_REQUEST['keyword'] ?? ''); diff --git a/api/document/upload.php b/api/document/upload.php index bec9786..e128148 100644 --- a/api/document/upload.php +++ b/api/document/upload.php @@ -5,13 +5,9 @@ * 存储:static/uploads/documents/年/月/随机文件名 * 返回:相对路径 storage_path */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['permissions' => ['document', 'competitor_data']]); -checkAjax(); -checkAnyPermission(['document', 'competitor_data']); if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { Response::error('请选择要上传的文件', 400); diff --git a/api/fragment/hard_add.php b/api/fragment/hard_add.php index bf50e8a..683a672 100644 --- a/api/fragment/hard_add.php +++ b/api/fragment/hard_add.php @@ -6,15 +6,9 @@ * target_product_category / description / source_channel * 写入 preliminary_data,recorded_by=当前登录用户(v1.0.50 起无 status 字段) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; -require_once __DIR__ . '/../common/validate.php'; -require_once __DIR__ . '/../common/duplicate_check.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'preliminary']); -checkAjax(); -checkPermission('preliminary'); $sourceType = trim($_POST['source_type'] ?? ''); if (!in_array($sourceType, ['company', 'person', 'product', 'need', 'mixed'], true)) { diff --git a/api/fragment/hard_convert.php b/api/fragment/hard_convert.php index e9b67e7..8de1289 100644 --- a/api/fragment/hard_convert.php +++ b/api/fragment/hard_convert.php @@ -6,16 +6,9 @@ * description, industry, need_category, application_scenario, contact_person * 逻辑:按目标类型写入主表(product/need 缺公司时自动建公司)→ 标记碎片已转换(converted_type/id, processed_at) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; -require_once __DIR__ . '/../common/completeness.php'; -require_once __DIR__ . '/../common/duplicate_check.php'; -require_once __DIR__ . '/../common/validate.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'preliminary']); -checkAjax(); -checkPermission('preliminary'); $id = (int)($_POST['id'] ?? 0); $targetType = trim($_POST['target_type'] ?? ''); diff --git a/api/fragment/hard_detail.php b/api/fragment/hard_detail.php index afca75d..28b3b49 100644 --- a/api/fragment/hard_detail.php +++ b/api/fragment/hard_detail.php @@ -2,12 +2,10 @@ /** * 硬碎片详情接口 GET /api/fragment/hard_detail.php?id=1 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'preliminary']); require_once __DIR__ . '/hard_common.php'; -checkPermission('preliminary'); $id = (int)($_REQUEST['id'] ?? 0); if ($id <= 0) { diff --git a/api/fragment/hard_discard.php b/api/fragment/hard_discard.php index f7dd251..b82105f 100644 --- a/api/fragment/hard_discard.php +++ b/api/fragment/hard_discard.php @@ -4,13 +4,9 @@ * 入参:id * v1.0.50:废弃改为从 preliminary_data 彻底硬删除(不再保留记录) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'preliminary']); -checkAjax(); -checkPermission('preliminary'); $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { diff --git a/api/fragment/hard_list.php b/api/fragment/hard_list.php index cd4d4df..1ecce4d 100644 --- a/api/fragment/hard_list.php +++ b/api/fragment/hard_list.php @@ -5,12 +5,10 @@ * 返回:{ list, total, page, limit },每行含完整度 score、缺失层级、内容摘要 * 说明:v1.0.50 起移除 status 字段(查看弹窗/数据表均不再展示状态),列表仅返回未转换(converted_id IS NULL)记录 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'preliminary']); require_once __DIR__ . '/hard_common.php'; -checkPermission('preliminary'); [$page, $limit] = pageParams(); $keyword = trim($_REQUEST['keyword'] ?? ''); diff --git a/api/fragment/hard_update.php b/api/fragment/hard_update.php index e2ab7ef..d93152c 100644 --- a/api/fragment/hard_update.php +++ b/api/fragment/hard_update.php @@ -4,22 +4,14 @@ * 仅更新硬碎片字段(企业名/联系人/手机/邮箱/身份证/社媒账号/主页链接),不执行转换。 * 转换请走 hard_convert.php(前端「转软碎片」按钮)。 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; -require_once __DIR__ . '/../common/validate.php'; -require_once __DIR__ . '/../common/duplicate_check.php'; +require_once __DIR__ . '/../common/Api.php'; -checkAjax(); -checkPermission('preliminary'); +$pdo = Api::boot(['module' => 'preliminary']); $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { Response::error('参数错误', 400); } - -$pdo = DB::getInstance()->getPdo(); $stmt = $pdo->prepare("SELECT * FROM preliminary_data WHERE id = ? AND is_active = 1"); $stmt->execute([$id]); $frag = $stmt->fetch(); @@ -45,6 +37,7 @@ $idNumber = trim($_POST['id_number'] ?? ''); $platform = trim($_POST['platform'] ?? ''); $accountId = trim($_POST['account_id'] ?? ''); $profileUrl = trim($_POST['profile_url'] ?? ''); +$sourceChannel = trim($_POST['source_channel'] ?? ($frag['source_channel'] ?? '')); // REV-4 来源渠道可编辑 if (!in_array($sourceType, ['company', 'person', 'product', 'need', 'mixed'], true)) { Response::error('碎片类型不合法', 400); } @@ -89,7 +82,8 @@ if (!empty($dups)) Response::duplicates($dups); $pdo->prepare( "UPDATE preliminary_data SET source_type = ?, company_name = ?, industry = ?, registration_number = ?, business_role = ?, country = ?, address = ?, - person_name = ?, contact_phone = ?, contact_email = ?, work_location = ?, id_number = ?, platform = ?, account_id = ?, profile_url = ? + person_name = ?, contact_phone = ?, contact_email = ?, work_location = ?, id_number = ?, platform = ?, account_id = ?, profile_url = ?, + source_channel = ? WHERE id = ?" )->execute([ $sourceType, @@ -107,6 +101,7 @@ $pdo->prepare( $platform !== '' ? $platform : null, $accountId !== '' ? $accountId : null, $profileUrl !== '' ? $profileUrl : null, + $sourceChannel !== '' ? $sourceChannel : null, $id, ]); @@ -117,6 +112,7 @@ logCurrent('update', 'fragment', 'preliminary_data', $id, [ 'phone' => $phone, 'email' => $email, 'id_number' => $idNumber, + 'source_channel' => $sourceChannel, ]); Response::success(null, '已保存,点击「转软碎片」完成转换'); diff --git a/api/fragment/soft_detail.php b/api/fragment/soft_detail.php index 271decc..b9453f3 100644 --- a/api/fragment/soft_detail.php +++ b/api/fragment/soft_detail.php @@ -4,12 +4,9 @@ * 返回:{ table, table_label, id, name, overall, level, layers, missing_core, missing_important, * missing_optional, missing_labels, row } */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/completeness.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'preliminary']); -checkPermission('preliminary'); $table = trim($_REQUEST['table'] ?? ''); $id = (int)($_REQUEST['id'] ?? 0); diff --git a/api/fragment/soft_list.php b/api/fragment/soft_list.php index 058c6da..4bbc2c7 100644 --- a/api/fragment/soft_list.php +++ b/api/fragment/soft_list.php @@ -6,12 +6,9 @@ * 每行:{ table, table_label, id, name, overall 整体完整度, level 缺失层级, * layer_rates {core,important,optional}, missing_fields 缺失字段(按核心→重要→非必要优先级), created_at } */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/completeness.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'preliminary']); -checkPermission('preliminary'); [$page, $limit] = pageParams(); $table = trim($_REQUEST['table'] ?? ''); diff --git a/api/fragment/soft_update.php b/api/fragment/soft_update.php index c421141..c68ac35 100644 --- a/api/fragment/soft_update.php +++ b/api/fragment/soft_update.php @@ -5,17 +5,9 @@ * 逻辑:更新主表字段 → 重新计算完整度 → 达标则 is_incomplete=0 * 特殊:persons 的 phone/email 写 social_accounts(platform=phone/email) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; -require_once __DIR__ . '/../common/helpers.php'; -require_once __DIR__ . '/../common/completeness.php'; -require_once __DIR__ . '/../common/duplicate_check.php'; -require_once __DIR__ . '/../common/validate.php'; +require_once __DIR__ . '/../common/Api.php'; -checkAjax(); -checkPermission('preliminary'); +$pdo = Api::boot(['module' => 'preliminary']); $table = trim($_POST['table'] ?? ''); $id = (int)($_POST['id'] ?? 0); @@ -23,8 +15,6 @@ if (!in_array($table, ['persons', 'companies', 'social_accounts', 'media'], true Response::error('参数错误', 400); } -$pdo = DB::getInstance()->getPdo(); - /** 按白名单收集 POST 字段 */ $fields = []; $collect = function ($whitelist) use (&$fields) { @@ -74,7 +64,9 @@ switch ($table) { $dup = findDuplicate($pdo, 'id_number', $fields['id_number'], [$id]); if ($dup) $dups[] = $dup; } - $ownAccountIds = $pdo->query("SELECT id FROM social_accounts WHERE owner_type = 'person' AND owner_id = $id")->fetchAll(PDO::FETCH_COLUMN); + $ownStmt = $pdo->prepare("SELECT id FROM social_accounts WHERE owner_type = 'person' AND owner_id = ?"); + $ownStmt->execute([$id]); + $ownAccountIds = $ownStmt->fetchAll(PDO::FETCH_COLUMN); if (isset($_POST['phone'])) { $dups = array_merge($dups, checkSingleAccountDuplicate($pdo, $_POST['phone'] !== '' ? 'phone' : '', $_POST['phone'], '', $ownAccountIds)); } diff --git a/api/fragment/stats_overview.php b/api/fragment/stats_overview.php index b64c251..fcacd54 100644 --- a/api/fragment/stats_overview.php +++ b/api/fragment/stats_overview.php @@ -10,11 +10,9 @@ * - pending 待处理碎片(硬待处理 + 软待处理) * 说明:v1.0.50 起 preliminary_data 无 status 字段,已转换以 converted_id 非空标识 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'preliminary']); -checkPermission('preliminary'); $pdo = DB::getInstance()->getPdo(); $monthStart = "DATE_FORMAT(CURDATE(), '%Y-%m-01')"; diff --git a/api/marketing/data_custom.php b/api/marketing/data_custom.php index 4405a3f..53d5a9e 100644 --- a/api/marketing/data_custom.php +++ b/api/marketing/data_custom.php @@ -3,16 +3,12 @@ * 数据定制接口(占位) GET/POST /api/marketing/data_custom.php * 后续扩展:定制数据需求提交、报价、交付等。 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'marketing']); -checkPermission('marketing'); $action = $_REQUEST['action'] ?? 'info'; if ($action === 'submit' && $_SERVER['REQUEST_METHOD'] === 'POST') { - checkAjax(); $content = [ 'contact_name' => trim($_POST['contact_name'] ?? ''), 'contact_phone' => trim($_POST['contact_phone'] ?? ''), diff --git a/api/marketing/edm.php b/api/marketing/edm.php index d98d067..6138ee1 100644 --- a/api/marketing/edm.php +++ b/api/marketing/edm.php @@ -7,11 +7,9 @@ * filter 按 行业 + 区域 + 关键词 筛选邮箱列表(persons.email + companies 关联联系人邮箱) * 入参(filter):industry / province / city / keyword / page / limit */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'marketing']); -checkPermission('marketing'); $action = $_REQUEST['action'] ?? 'filter'; $pdo = DB::getInstance()->getPdo(); diff --git a/api/marketing/edm_export.php b/api/marketing/edm_export.php index 4acc3cb..2eb3657 100644 --- a/api/marketing/edm_export.php +++ b/api/marketing/edm_export.php @@ -3,12 +3,9 @@ * EDM 邮箱列表导出接口(CSV) GET/POST /api/marketing/edm_export.php * 入参同 edm.php filter:industry / province / city / keyword */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'marketing']); -checkPermission('marketing'); // 仅导出勾选的邮箱(emails 逗号分隔) $emailsRaw = trim($_REQUEST['emails'] ?? ''); diff --git a/api/marketing/media_wholesale.php b/api/marketing/media_wholesale.php index c5939aa..654ac9d 100644 --- a/api/marketing/media_wholesale.php +++ b/api/marketing/media_wholesale.php @@ -3,16 +3,12 @@ * 媒体批发接口(占位) GET/POST /api/marketing/media_wholesale.php * 后续扩展:媒体资源批量询价、下单等。 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'marketing']); -checkPermission('marketing'); $action = $_REQUEST['action'] ?? 'info'; if ($action === 'inquiry' && $_SERVER['REQUEST_METHOD'] === 'POST') { - checkAjax(); $content = [ 'media_ids' => trim($_POST['media_ids'] ?? ''), 'remark' => trim($_POST['remark'] ?? ''), diff --git a/api/media/add.php b/api/media/add.php index c6bd090..4cec81e 100644 --- a/api/media/add.php +++ b/api/media/add.php @@ -4,16 +4,9 @@ * 必填:owner_type / owner_id / platform / account_id * 可选:profile_url / remark / is_primary + 商业属性(account_level/certification_type/follower_count 等) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; -require_once __DIR__ . '/../common/helpers.php'; -require_once __DIR__ . '/../common/completeness.php'; -require_once __DIR__ . '/../common/duplicate_check.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'media']); -checkAjax(); -checkPermission('media'); $data = extractFields(MEDIA_FIELDS); if (!in_array($data['owner_type'] ?? '', ['person', 'company'], true)) { diff --git a/api/media/delete.php b/api/media/delete.php index 23eef0a..16c953c 100644 --- a/api/media/delete.php +++ b/api/media/delete.php @@ -3,13 +3,9 @@ * 媒体数据删除接口(软删除) POST /api/media/delete.php * 入参:id(或 ids 逗号分隔批量) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'media']); -checkAjax(); -checkPermission('media'); $id = (int)($_POST['id'] ?? 0); $ids = trim($_POST['ids'] ?? ''); diff --git a/api/media/detail.php b/api/media/detail.php index 93f5dce..2e67021 100644 --- a/api/media/detail.php +++ b/api/media/detail.php @@ -2,11 +2,9 @@ /** * 媒体数据详情接口 GET /api/media/detail.php?id=1 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'media']); -checkPermission('media'); $id = (int)($_REQUEST['id'] ?? 0); if ($id <= 0) { diff --git a/api/media/export.php b/api/media/export.php index a5fe9a9..eb65b33 100644 --- a/api/media/export.php +++ b/api/media/export.php @@ -3,12 +3,9 @@ * 媒体数据导出接口(CSV) GET/POST /api/media/export.php * 参数同 list.php */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'media']); -checkPermission('media'); // 仅导出勾选的媒体账号 $idsRaw = trim($_REQUEST['ids'] ?? ''); diff --git a/api/media/import.php b/api/media/import.php index b025849..518f078 100644 --- a/api/media/import.php +++ b/api/media/import.php @@ -5,14 +5,9 @@ * account_level,content_categories,follower_count,avg_read_count,certification_type * owner_type 为 person/company 时 owner_id 也可填对应名称(display_name/full_name),自动解析为ID。 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; -require_once __DIR__ . '/../common/completeness.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'media']); -checkAjax(); -checkPermission('media'); if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { Response::error('请选择要上传的CSV文件', 400); diff --git a/api/media/list.php b/api/media/list.php index ca023bd..fa82dbc 100644 --- a/api/media/list.php +++ b/api/media/list.php @@ -4,11 +4,9 @@ * 参数:page / limit / keyword / platform / certification_type / account_level / owner_type / is_active * 数据源:social_accounts JOIN media_commercial_attributes */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'media']); -checkPermission('media'); [$page, $limit] = pageParams(); $keyword = trim($_REQUEST['keyword'] ?? ''); diff --git a/api/media/update.php b/api/media/update.php index 6978efc..102c86e 100644 --- a/api/media/update.php +++ b/api/media/update.php @@ -3,16 +3,9 @@ * 媒体数据编辑接口 POST /api/media/update.php * 入参:id + 白名单字段(含商业属性) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; -require_once __DIR__ . '/../common/helpers.php'; -require_once __DIR__ . '/../common/completeness.php'; -require_once __DIR__ . '/../common/duplicate_check.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'media']); -checkAjax(); -checkPermission('media'); $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { diff --git a/api/need/add.php b/api/need/add.php index b067d1b..a3fa273 100644 --- a/api/need/add.php +++ b/api/need/add.php @@ -3,14 +3,9 @@ * 需求新增接口 POST /api/need/add.php * 必填:company_id;可选白名单字段见 NEED_FIELDS */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; -require_once __DIR__ . '/../common/helpers.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'need']); -checkAjax(); -checkPermission('need'); $data = extractFields(NEED_FIELDS); if (empty($data['company_id'])) { diff --git a/api/need/delete.php b/api/need/delete.php index 1327e87..964ff5d 100644 --- a/api/need/delete.php +++ b/api/need/delete.php @@ -3,13 +3,9 @@ * 需求删除接口(作废) POST /api/need/delete.php * 入参:id(或 ids 逗号分隔批量) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'need']); -checkAjax(); -checkPermission('need'); $id = (int)($_POST['id'] ?? 0); $ids = trim($_POST['ids'] ?? ''); diff --git a/api/need/detail.php b/api/need/detail.php index f47d306..89e5e0b 100644 --- a/api/need/detail.php +++ b/api/need/detail.php @@ -2,11 +2,9 @@ /** * 需求详情接口 GET /api/need/detail.php?id=1 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'need']); -checkPermission('need'); $id = (int)($_REQUEST['id'] ?? 0); if ($id <= 0) { diff --git a/api/need/list.php b/api/need/list.php index 2e7664f..8b65027 100644 --- a/api/need/list.php +++ b/api/need/list.php @@ -3,11 +3,9 @@ * 需求转盘列表接口 GET/POST /api/need/list.php * 参数:page / limit / keyword / start_date / end_date / company_name / industry / need_category / is_valid */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'need']); -checkPermission('need'); [$page, $limit] = pageParams(); $keyword = trim($_REQUEST['keyword'] ?? ''); diff --git a/api/need/push.php b/api/need/push.php index 44e70ea..665ebfc 100644 --- a/api/need/push.php +++ b/api/need/push.php @@ -5,13 +5,9 @@ * 说明:当前为占位实现,仅写日志并返回成功; * 后续可扩展为调用第三方API推送JSON数据 / 通过SMTP发送邮件。 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'need']); -checkAjax(); -checkPermission('need'); $id = (int)($_POST['id'] ?? 0); $ids = trim($_POST['ids'] ?? ''); diff --git a/api/need/update.php b/api/need/update.php index eea619c..3926987 100644 --- a/api/need/update.php +++ b/api/need/update.php @@ -3,14 +3,9 @@ * 需求编辑接口 POST /api/need/update.php * 入参:id + 白名单字段(可含 is_valid 作废/恢复) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; -require_once __DIR__ . '/../common/helpers.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'need']); -checkAjax(); -checkPermission('need'); $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { diff --git a/api/person/add.php b/api/person/add.php index 5d0bea2..718d9cd 100644 --- a/api/person/add.php +++ b/api/person/add.php @@ -4,17 +4,9 @@ * 必填:full_name;union_id 为空时自动生成。 * 可选:contacts(JSON 数组,如 [{"platform":"email","account_id":"a@b.com","is_primary":1}]) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; -require_once __DIR__ . '/../common/helpers.php'; -require_once __DIR__ . '/../common/completeness.php'; -require_once __DIR__ . '/../common/duplicate_check.php'; -require_once __DIR__ . '/../common/validate.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'person']); -checkAjax(); -checkPermission('person'); $data = extractFields(PERSON_FIELDS); if (empty($data['full_name'])) { diff --git a/api/person/companies.php b/api/person/companies.php index b52c5dc..820eb2f 100644 --- a/api/person/companies.php +++ b/api/person/companies.php @@ -4,11 +4,9 @@ * 入参:person_id(必填)/ page / limit * 返回:该人员任职的公司列表(公司名称/部门/岗位/职级/入职日期/是否在岗) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'person']); -checkPermission('person'); $personId = (int)($_REQUEST['person_id'] ?? 0); if ($personId <= 0) { diff --git a/api/person/company_options.php b/api/person/company_options.php index 033848f..e1b1970 100644 --- a/api/person/company_options.php +++ b/api/person/company_options.php @@ -4,11 +4,9 @@ * 返回启用中的企业(id + 显示名称),供人员「工作履历」行内公司下拉使用 * 入参:keyword(可选,模糊匹配企业名称)/ limit(默认 500) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'person']); -checkPermission('person'); $keyword = trim($_REQUEST['keyword'] ?? ''); $limit = min(1000, max(1, (int)($_REQUEST['limit'] ?? 500))); diff --git a/api/person/connections.php b/api/person/connections.php index 91cf945..b0a49a8 100644 --- a/api/person/connections.php +++ b/api/person/connections.php @@ -12,11 +12,9 @@ * 入参:person_id(必填)/ limit(默认 50) * 返回:{ list: [{id, full_name, phone, tags:[老乡,校友,同事], intimacy}] } */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'person']); -checkPermission('person'); $personId = (int)($_REQUEST['person_id'] ?? 0); if ($personId <= 0) { diff --git a/api/person/delete.php b/api/person/delete.php index 7346077..7b85c50 100644 --- a/api/person/delete.php +++ b/api/person/delete.php @@ -3,13 +3,9 @@ * 人员删除接口(软删除) POST /api/person/delete.php * 入参:id(或 ids 逗号分隔批量) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'person']); -checkAjax(); -checkPermission('person'); $id = (int)($_POST['id'] ?? 0); $ids = trim($_POST['ids'] ?? ''); diff --git a/api/person/detail.php b/api/person/detail.php index 7325fb5..d619de9 100644 --- a/api/person/detail.php +++ b/api/person/detail.php @@ -3,11 +3,9 @@ * 人员详情接口 GET /api/person/detail.php?id=1 * v1.0.16:联系方式改为“手机”“邮箱”两字段(仅常用);另有全部账号列表 accounts 供编辑回显 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'person']); -checkPermission('person'); $id = (int)($_REQUEST['id'] ?? 0); if ($id <= 0) { diff --git a/api/person/export.php b/api/person/export.php index 2f2ad54..ceccfd6 100644 --- a/api/person/export.php +++ b/api/person/export.php @@ -3,12 +3,9 @@ * 人员导出接口(CSV) GET/POST /api/person/export.php * 参数同 list.php */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'person']); -checkPermission('person'); // 仅导出勾选的人员 $idsRaw = trim($_REQUEST['ids'] ?? ''); diff --git a/api/person/import.php b/api/person/import.php index aec5aab..37b1927 100644 --- a/api/person/import.php +++ b/api/person/import.php @@ -5,14 +5,9 @@ * graduated_from,hometown,work_location,phone,email,source_channel,source_detail * 仅 full_name 必填;union_id 留空自动生成;phone/email 写入 social_accounts(默认常用)。 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; -require_once __DIR__ . '/../common/completeness.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'person']); -checkAjax(); -checkPermission('person'); if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { Response::error('请选择要上传的CSV文件', 400); diff --git a/api/person/list.php b/api/person/list.php index f0ca205..781aa0d 100644 --- a/api/person/list.php +++ b/api/person/list.php @@ -5,11 +5,9 @@ * 返回:{ list, total, page, limit } * v1.0.16:联系方式列改为“手机”“邮箱”两个字段(仅返回常用:is_defult=1 优先,其次 is_primary=1) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'person']); -checkPermission('person'); [$page, $limit] = pageParams(); $keyword = trim($_REQUEST['keyword'] ?? ''); diff --git a/api/person/products.php b/api/person/products.php index 70423e9..3cfdb46 100644 --- a/api/person/products.php +++ b/api/person/products.php @@ -4,11 +4,9 @@ * 入参:person_id(必填)/ page / limit * 数据源:该人员任职公司的产品品类(去重) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'person']); -checkPermission('person'); $personId = (int)($_REQUEST['person_id'] ?? 0); if ($personId <= 0) { diff --git a/api/person/update.php b/api/person/update.php index 85ebe5d..183da01 100644 --- a/api/person/update.php +++ b/api/person/update.php @@ -3,17 +3,9 @@ * 人员编辑接口 POST /api/person/update.php * 入参:id + 白名单字段;可选 contacts(JSON 数组,整体替换联系方式) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; -require_once __DIR__ . '/../common/helpers.php'; -require_once __DIR__ . '/../common/completeness.php'; -require_once __DIR__ . '/../common/duplicate_check.php'; -require_once __DIR__ . '/../common/validate.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'person']); -checkAjax(); -checkPermission('person'); $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { diff --git a/api/preliminary/list.php b/api/preliminary/list.php index 972ae5f..8c9e5f6 100644 --- a/api/preliminary/list.php +++ b/api/preliminary/list.php @@ -3,11 +3,9 @@ * 碎片处理列表接口(待定逻辑,暂做基础列表) GET/POST /api/preliminary/list.php * 参数:page / limit / keyword / source_type / start_date / end_date */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'preliminary']); -checkPermission('preliminary'); [$page, $limit] = pageParams(); $keyword = trim($_REQUEST['keyword'] ?? ''); diff --git a/api/preliminary/stats.php b/api/preliminary/stats.php index 6a54756..f65ccdc 100644 --- a/api/preliminary/stats.php +++ b/api/preliminary/stats.php @@ -3,11 +3,9 @@ * 碎片处理统计接口 GET /api/preliminary/stats.php * 返回:总数 / 今日新增 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'preliminary']); -checkPermission('preliminary'); $pdo = DB::getInstance()->getPdo(); diff --git a/api/system/log_list.php b/api/system/log_list.php index ba1a1a4..9a8f73e 100644 --- a/api/system/log_list.php +++ b/api/system/log_list.php @@ -4,11 +4,9 @@ * 仅超级管理员可见,默认查询近1个月 * 参数:page / limit / keyword / action / module / username / start_date / end_date */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['super' => true]); -requireSuperAdmin(); [$page, $limit] = pageParams(); $keyword = trim($_REQUEST['keyword'] ?? ''); diff --git a/api/system/role_add.php b/api/system/role_add.php index 41cce5b..50c524d 100644 --- a/api/system/role_add.php +++ b/api/system/role_add.php @@ -3,13 +3,9 @@ * 新增角色接口(含权限分配) POST /api/system/role_add.php * 入参:role_name / permissions(JSON数组,菜单标识)/ is_active */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'system']); -checkAjax(); -checkPermission('system'); $roleName = trim($_POST['role_name'] ?? ''); $perms = json_decode($_POST['permissions'] ?? '[]', true); diff --git a/api/system/role_delete.php b/api/system/role_delete.php index 61e8205..be9bca7 100644 --- a/api/system/role_delete.php +++ b/api/system/role_delete.php @@ -4,13 +4,9 @@ * 入参:id(或 ids 逗号分隔批量) * 保护:不允许删除内置超级管理员角色(id=1)及仍被用户使用的角色 */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'system']); -checkAjax(); -checkPermission('system'); $id = (int)($_POST['id'] ?? 0); $ids = trim($_POST['ids'] ?? ''); diff --git a/api/system/role_list.php b/api/system/role_list.php index 82bd340..2859994 100644 --- a/api/system/role_list.php +++ b/api/system/role_list.php @@ -3,11 +3,9 @@ * 角色列表接口 GET/POST /api/system/role_list.php * 参数:page / limit / keyword / is_active */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'system']); -checkPermission('system'); [$page, $limit] = pageParams(); $keyword = trim($_REQUEST['keyword'] ?? ''); diff --git a/api/system/role_update.php b/api/system/role_update.php index 840e068..92bf10f 100644 --- a/api/system/role_update.php +++ b/api/system/role_update.php @@ -3,13 +3,9 @@ * 编辑角色接口(含权限分配) POST /api/system/role_update.php * 入参:id / role_name / permissions(JSON数组)/ is_active */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'system']); -checkAjax(); -checkPermission('system'); $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { diff --git a/api/system/user_add.php b/api/system/user_add.php index 9b8a490..561167c 100644 --- a/api/system/user_add.php +++ b/api/system/user_add.php @@ -3,13 +3,9 @@ * 新增用户接口 POST /api/system/user_add.php * 入参:username / password / real_name / role_id / is_active */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; -checkAjax(); -checkPermission('system'); +$pdo = Api::boot(['module' => 'system']); $username = trim($_POST['username'] ?? ''); $password = (string)($_POST['password'] ?? ''); @@ -20,15 +16,13 @@ $isActive = isset($_POST['is_active']) ? ((int)$_POST['is_active'] ? 1 : 0) : 1; if ($username === '' || !preg_match('/^[a-zA-Z0-9_]{3,50}$/', $username)) { Response::error('账号需为3-50位字母/数字/下划线', 400); } -if (strlen($password) < 6) { - Response::error('密码长度不能少于6位', 400); +if (strlen($password) < 8 || !preg_match('/[a-zA-Z]/', $password) || !preg_match('/\d/', $password)) { + Response::error('密码需不少于8位,且同时包含字母和数字', 400); } if ($roleId <= 0) { Response::error('请选择角色', 400); } -$pdo = DB::getInstance()->getPdo(); - $chk = $pdo->prepare("SELECT COUNT(*) FROM system_users WHERE username = ?"); $chk->execute([$username]); if ((int)$chk->fetchColumn() > 0) { @@ -45,7 +39,7 @@ $stmt = $pdo->prepare( "INSERT INTO system_users (username, password, real_name, role_id, is_active) VALUES (?, ?, ?, ?, ?)" ); -$stmt->execute([$username, sha1($password), $realName !== '' ? $realName : null, $roleId, $isActive]); +$stmt->execute([$username, password_hash($password, PASSWORD_DEFAULT), $realName !== '' ? $realName : null, $roleId, $isActive]); $newId = (int)$pdo->lastInsertId(); logCurrent('add', 'system', 'system_users', $newId, ['username' => $username, 'role_id' => $roleId, 'is_active' => $isActive]); diff --git a/api/system/user_delete.php b/api/system/user_delete.php index a334f53..0436c91 100644 --- a/api/system/user_delete.php +++ b/api/system/user_delete.php @@ -4,13 +4,9 @@ * 入参:id(或 ids 逗号分隔批量) * 保护:不允许删除内置管理员(id=1) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'system']); -checkAjax(); -checkPermission('system'); $id = (int)($_POST['id'] ?? 0); $ids = trim($_POST['ids'] ?? ''); diff --git a/api/system/user_list.php b/api/system/user_list.php index abfeace..f0e4cef 100644 --- a/api/system/user_list.php +++ b/api/system/user_list.php @@ -3,11 +3,9 @@ * 用户列表接口 GET/POST /api/system/user_list.php * 参数:page / limit / keyword / role_id / is_active */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; +require_once __DIR__ . '/../common/Api.php'; +$pdo = Api::boot(['module' => 'system']); -checkPermission('system'); [$page, $limit] = pageParams(); $keyword = trim($_REQUEST['keyword'] ?? ''); diff --git a/api/system/user_update.php b/api/system/user_update.php index 0753d32..e36c506 100644 --- a/api/system/user_update.php +++ b/api/system/user_update.php @@ -3,20 +3,14 @@ * 编辑用户接口(含角色分配) POST /api/system/user_update.php * 入参:id / real_name / role_id / is_active / password(可选,留空不改密码) */ -require_once __DIR__ . '/../common/db.php'; -require_once __DIR__ . '/../common/response.php'; -require_once __DIR__ . '/../common/auth.php'; -require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/Api.php'; -checkAjax(); -checkPermission('system'); +$pdo = Api::boot(['module' => 'system']); $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { Response::error('参数错误', 400); } - -$pdo = DB::getInstance()->getPdo(); $check = $pdo->prepare("SELECT * FROM system_users WHERE id = ?"); $check->execute([$id]); $old = $check->fetch(); @@ -52,11 +46,11 @@ if (isset($_POST['is_active'])) { } $password = (string)($_POST['password'] ?? ''); if ($password !== '') { - if (strlen($password) < 6) { - Response::error('密码长度不能少于6位', 400); + if (strlen($password) < 8 || !preg_match('/[a-zA-Z]/', $password) || !preg_match('/\d/', $password)) { + Response::error('密码需不少于8位,且同时包含字母和数字', 400); } $sets[] = 'password = ?'; - $params[] = sha1($password); + $params[] = password_hash($password, PASSWORD_DEFAULT); } if (!$sets) { diff --git a/docs/superlink_产品评测与优化路线图_2026.md b/docs/superlink_产品评测与优化路线图_2026.md new file mode 100644 index 0000000..a087396 --- /dev/null +++ b/docs/superlink_产品评测与优化路线图_2026.md @@ -0,0 +1,230 @@ +# SuperLink 产品深度评测 · 竞争格局 · 增长助力 · 优化路线图 + +> 作用对象:SuperLink(`mysuperlink` 管理系统,当前 v1.0.50) +> 关联主体:千视科技(深圳市宝安区西乡街道 · 官网 https://qianshi.cool/) +> 撰写日期:2026-09 +> 说明:竞争情报部分基于海外外行业既有认知整理;官网信息为当日抓取自 qianshi.cool,代码信息来自本仓库源码精读。 + +--- + +## 一、产品核心优势与壁垒 + +### 1.1 一句话定位 + +**SuperLink 是千视科技"AI拓客"产品线中的企业数据引擎**——把零散的企业/人员/产品/需求线索("碎片")加工成结构化、可衡量、可跟进的高质量主库数据,再用于 EDM/精准触达与渠道分析,最终把"潜在客户"变成"成交线索"。官方价值主张:"AI 驱动的精准触达,把每个潜在客户变成成交线索"。 + +### 1.2 六大核心优势(基于代码实际能力) + +| # | 优势 | 实现载体(代码) | 说明 | +|---|---|---|---| +| 1 | **碎片化线索治理方法论**(独特) | `preliminary_data` 硬碎片 + 主表软碎片 + `common/completeness.php` | 市场上几乎没有产品把"不完整数据"这一数据治理场景做成产品化流程:硬碎片(未入主表)/软碎片(主表完整度低)分治,三层完整度引擎(核心100%/重要60%/非必要40%)自动判级 `is_incomplete` | +| 2 | **按类型的字段适配** | `hard_convert.php` / `hard_add.php` | 企业/人员/产品/需求四类新增与补全弹窗严格按类型呈现字段,前后端双校验(如企业转软需名称+行业+注册号),大幅降低录入歧义 | +| 3 | **渠道归因与效能分析** | `channel/analysis.php` + `source_channel/source_detail` 字段 | 每条企业/人员可溯来源渠道,饼图 + source_detail TOP10,支撑"哪种渠道值得投入"的获客决策 | +| 4 | **国产生态语义深耕** | `config.js` CN_TO_EN、`common/phone_geo.php`、`data_dict_*` | 身份证/手机归属地/省市级/work_location、企业类型/注册号/社媒平台、人脉亲密度(老乡/校友/同事算法 `person/connections.php`)——全是面向中国 B2B 的场景化语义,通用 CRM 给不了 | +| 5 | **多态社交账号模型** | `social_accounts`(owner_type+owner_id) | 企业、人员、自媒体共用一张账号表,phone/email/社媒统一建模,为 EDM/企微/短信触达打下数据基础 | +| 6 | **轻量可私有化、可定制** | 纯 PHP + MySQL、无框架、单文件即可部署 | 契合千视"软件定制"业务:交付给客户时成本低、改动快、数据不出域 | + +### 1.3 壁垒(护城河)分析 + +**已具备的护城河:** +- **流程型方法论壁垒**:碎片→补全→转换→跟进→渠道评估的闭环,属于"组织 Know-how",对手抄起来要重做整套业务设计,且需要真实业务数据打磨。 +- **数据资产积累壁垒(潜在)**:`companies / persons / social_accounts / preliminary_data` 越用越全。客户把数据喂进来,切换成本高(类似 CRM 的数据锁)。 +- **AI 生态协同壁垒**:背上千视的 RAG 知识库 / AI 客服 / 数字人 / OPC,SuperLink 可随时"接 AI 补全、AI 拓客"——单一获客工具难以复制整套 AI 增长生态。 + +**当前护城河偏弱的环节(风险):** +- 代码层还没真正实现官网承诺的 **AI 触达 / 多渠道自动跟进 / 线索自动流转 / ROI 面板**(详见第四部分差距分析),壁垒目前更多停留在"数据管理流程"而非"AI 能力闭环",技术护城河不深。 +- 数据不依赖第三方工商接口,靠手动/人工补录,数据广度(全量企业库)不如天眼查/企查查。 +- 无专利、无社区、无开放生态,网络效应取决于千视自身的客户规模。 + +**结论**:SuperLink 的护城河是"**B2B 数据治理方法论 + 中国产业语义 + 可私有化定制 + 千视 AI 生态**"的组合拳,单点都不深,但组合起来(尤其 AI 闭环跑通后)具备可持续性。全力补齐 AI 触达与 ROI 闭环,是护城河"由浅入深"的关键一跃。 + +--- + +## 二、竞品评测与横向比较 + +> 由于本次 web 检索服务不可用,以下结论基于对该成熟市场的既有认知整理;建议在实际决策前人工复核最新版本与报价。 + +### 2.1 赛道地图:四类产品 + +| 类别 | 代表产品 | 与 SuperLink 的本质关系 | +|---|---|---| +| **A. 企业数据情报平台** | 天眼查、企查查、启信宝、爱企查(百度)、水滴筹系企业预警通 | 数据来源/数据宽度上的"上帝"/供应商,SuperLink 不必与它们正面拼数据量 | +| **B. 智能销售 / 获客 SaaS** | 探迹 Tungee、销氪、励销云、纷享销客、销售易 | 直接竞品,拼"线索→成交"闭环 | +| **C. 开源 CRM / 营销自动化** | EspoCRM、SuiteCRM、Odoo CRM、Twenty、Mautic、vTiger | 自建替代品,拼"能否更懂中国 B2B" | +| **D. 私域/SCRM** | 微盟、有赞、企点、WeCom 衍生 | 侧重 ToC 私域带货,交集小 | + +### 2.2 A 类:企业工商 / 数据情报平台 + +| | 天眼查 | 企查查 | 启信宝 | **SuperLink** | +|---|---|---|---|---| +| 定位 | 企业信息查询/商业查询 | 企业信息查询 | 企业信用与信息 | 内部获客数据引擎 | +| 数据源 | 工商/司法/知识产权/舆情 | 同左 | 同左 | 客户自有 + 录入/导入 | +| 数据广度 | ★★★★★(数亿家企业) | ★★★★★ | ★★★★☆ | ★★(仅录入部分) | +| 获客闭环 | 无("查"不"跟") | 无 | 无 | **有(碎片→主表→EDM→渠道)** | +| 私有化部署 | 不支持 | 不支持 | B端部分可 | **完全私有化** | +| 成本 | 高(B 端高净值) | 高 | 中 | 私有化一次投入/定制 | +| **对 SuperLink 的意义** | 理论上是**上游数据供应方**——可通过接口/导入把天眼查数据灌进 SuperLink 结构化跟进。**竞合而非正面竞争** | + +### 2.3 B 类:智能销售 / 获客 SaaS(直接竞品) + +| | 探迹 Tungee | 销氪 / 励销云 | 纷享销客 / 销售易 | **SuperLink** | +|---|---|---|---|---| +| 交付模式 | SaaS 租用 | SaaS 租用 | SaaS 租用 | **私有化 + 定制** | +| 核心 | AI 获客 + 电销/外呼 | 智能获客 + 转化 | 销售过程 CRM 管理 | 数据引擎 + 碎片治理 + 渠道归因 | +| 中国数据广度 | ★★★★★(对接企查查类) | ★★★★☆ | ★★★☆ | ★★(自有数据) | +| 数据私有/主权 | 弱(数据在 SaaS 平台) | 弱 | 中 | **强(完全在客户/自己手里)** | +| 定制深度 | 标准化 | 半标准化 | 半标准化 | **深度贴合业务** | +| 竞品优劣势 | 胜在开箱即用+数据全 | 胜在性价比 | 胜在流程管理 | **胜在中大型/数据敏感客户、私有化、定制** | +| 价格 | 中高 | 中 | 中高 | 一次性 + 服务 | + +**竞争结论**:SuperLink 不与探迹等拼"开箱即用+数据海量",而是打**"数据主权 + 私有化 + 深度定制 + 千视 AI 生态"**。对想掌握自己数据、不愿上 SaaS、要按行业语义定制的 B2B 客户(尤其进出口、制造、ToB 服务商)有明显利基。 + +### 2.4 C 类:开源 CRM / 营销自动化 + +| | EspoCRM | SuiteCRM/Sugar | Odoo CRM | Twenty | Mautic | **SuperLink** | +|---|---|---|---|---|---|---| +| 免费/开源 | 开源 | 开源/商用 | 开源核心 | 开源 | 开源 | 闭源自有 | +| 功能广度 | 中 | 大 | 大 | 中小 | 营销自动化 | 中(专注数据) | +| 中国 B2B 语义 | ✗ | ✗ | ✗ | ✗ | ✗ | **✓(注册号/社媒/人脉/IP归属)** | +| 私有化 | ✓ | ✓ | ✓ | ✓ | ✓ | **✓** | +| 上手定制成本 | 低-中 | 高(老派重) | 中 | 低(现代 TS) | 中 | 低-中(纯PHP直改) | +| 数据库自由 | 需其 schema | 需其 schema | 需其 schema | 需其 schema | 需其 schema | **直接用自有 Mysql 表** | + +**开源对比结论**:主流开源 CRM 是"通用重型业务系统",SuperLink 的优势在于**面向中国 B2B 的行业语义 + 轻量直改 + 数据表/流程可控**,但劣势是没有开源社区的插件生态与持续更新。若考虑开源化,Twenty(现代技术栈)/ Mautic(营销自动化)最值得借鉴,可作为功能对标对象。 + +### 2.5 竞品评测总结定位 + +**一句话差异化定位**: +> SuperLink = **企业数据治理方法论引擎 × 中国 B2B 产业语义 × 可私有化定制 ×(待补齐)AI 触达闭环**。既不与天眼查拼"查",也不与探迹拼"卖",而是吃下"数据→质量→触达→归因"这条自建链条,尤其服务**数据敏感、要私有化、要深度定制的 B2B 客户**。 + +| 对比维度 | SuperLink 相对优势 | SuperLink 相对劣势 | +|---|---|---| +| 数据主权/私有化 | ★★★★★ | — | +| 中国 B2B 行业语义 | ★★★★★ | — | +| 数据广度 | — | ★★(不拼全量库) | +| AI 触达闭环 | — | ★★(**尚未实现,最大短板**) | +| 开箱即用 | — | ★★★(需定制/初始化) | +| 生态/社区 | — | ★(无开源生态) | + +--- + +## 三、千视官网解读 · SuperLink 如何助力获客与销售 + +### 3.1 千视科技是谁 + +抓取自官网 https://qianshi.cool/ : +- **千视科技 · 一站式 AI 增长引擎**:企业智能体 · AI 拓客 · AI 推广 · 软件定制,用 AI 驱动企业全链路增长。深圳宝安。 +- 6 大业务线:**企业智能体**(RAG知识库 / AI客服 / 数字人/视频复刻 / OPC一人公司)、**AI 拓客**(SuperLink + EDM 邮件)、**AI 推广**(自媒体代运营 / GEO·SEO·SEM / 舆情管理)、**软件定制**、媒体宣发、EDM。 +- 规模宣传:服务客户 500+、AI 解决方案 10、客户端位行业覆盖 100、7×24 服务;客户 logo 含腾讯云/阿里云/字节/华为云/比亚迪/中国移动等。 +- 官网 SuperLink 专页(/superlink.html)价值主张:"AI 驱动的精准触达,把每个潜在客户变成成交线索"。 + - 精准客户画像(行业/规模/需求多维建模、锁定高意向客户) + - 智能触达链路(AI 生成个性化触达内容,企微/邮件/短信多渠道自动跟进) + - 线索自动流转(识别意向自动标记推送销售,全程记录跟进轨迹) + - ROI 可视化(获客成本/转化率/成交额数据面板) +- 客户证言:"某 SaaS 企业用 SuperLink 三个月新增 200+ 精准线索,获客成本降 40%。" + +### 3.2 千视的商业模式(销售漏斗) + +千视本质是**"AI 增长解决方案 + 软件定制"服务商**,获客漏斗大致为: +``` +投放/内容(博客/案例/官网) → 留资/咨询 → 诊断(需求挖掘) → 方案演示 → 交付(定制/智能体/拓客) +``` +SuperLink 在其中的角色不只是"一个产品",而是**千视销售体系的撬动点**。 + +### 3.3 SuperLink 助力获客与销售的四大路径 + +**路径一:产品化"客户案例"——成为官网获客钩子** +官网已把 SuperLink 作为 AI 拓客代表产品 + 客户证言页。可进一步把**用 SuperLink 做出来的真实客户/行业/地区分布数据**做成可下载的《拓客白皮书》《行业线索榜单》等营销资料中心内容(官网已有"营销资料中心"栏目),用"能帮忙制造线索"的产品去吸引 B2B 客户留资。 + +**路径二:卖"方法论"而不只是"软件"** +SuperLink 本身就是千视获客方法的**可执行化**。可作为咨询/交付的第一公里: +- 帮客户建"企业+人员+产品+需求"四类种子库(用千视已有客户数据、合作渠道灌入) +- 用碎片处理流程演示"线索加工"能力,体现千视懂业务 +- 后续接 RAG 知识库 / AI 客服(企业智能体)/ EDM / GEO(AI 推广)做增购和交叉销售 + +**路径三:EDM 与精准触达的"获客弹药"** +SuperLink 的渠道归因 + 联系方式建模(social_accounts 的 email/phone)直接喂给 EDM 邮件与企微/短信触达环节——**客户库从产品里来,触达在千视 EDM/企微里完成**,形成"数据通了"的闭环卖点。 + +**路径四:销售过程的内部效率工具 + 样板** +千视自己的销售/交付团队若用 SuperLink 管理其线索(公司、联系人、需求、跟进、渠道),既是内部提效,又成为对外演示的"活样板"(dogfooding),销售演示时可直接讲"我们内部就是这么用的"。 + +### 3.4 关键洞察(差距) + +官网承诺的四大能力是**目标态 / 营销态**,而当前代码还停留在**数据治理态**。要让"助力获客和销售"从口号变成真能力,必须把官网承诺的 AI 触达 / 多渠道自动跟进 / 线索自动流转 / ROI 面板在代码里落地(见第四部分 P0 项)。**营销与实现之间有一条必须补齐的鸿沟**——这既是风险也是机会。 + +--- + +## 四、代码架构与产品功能完善 / 优化建议 + +### 4.1 架构层面(基建,按优先级) + +**[P0] 引入迁移版本管理**——现在唯一落地风险点 +- 现状:`sql/migration_v1.0.XX.sql` 每次升级手工执行,无 `schema_versions` 表校验,多环境易漏跑、无回滚。 +- 建议:新增 `schema_versions(version, applied_at, checksum)` + `tools/migrate.php` 顺序执行未应用的迁移,并把 v1.0.48 遗留的 `preliminary_data_bak_*` 冗余备份表清理/收编。 + +**[P0] 抽一层"通用 CRUD / 服务层",收敛重复样板** +- 现状:每个 `api/<模块>/<动作>.php` 各自堆"require db/response/auth + checkAjax + checkPermission + SQL"重复代码;多个模块的 list/add/update/delete 高度同构。 +- 建议:建 `common/Api.php`(统一的参数解析、鉴权、JSON 输出、日志、分页)与 `common/Service.php`(按四表 + 子表的标准 CRUD),让新模块 10 分钟出一个,降低维护成本、统一错误规范。 + +**[P1] 鉴权与安全加固** +- 现状:密码 SHA1 未加盐;CSRF 仅靠 `X-Requested-With` 头。 +- 建议:升级为 `password_hash()/password_verify()`(至少 bcrypt),对老用户做"登录时平滑迁移";写接口补 CSRF token(Session 绑定);登录接口加简单限流/失败锁定,防爆破。 +- 现状 `app_version` 手动改:可让 `tools/bump_version.php` 同时更新 config.js 与前端角标,避免遗漏。 + +**[P1] 前端架构解耦** +- 现状:`fragment.js` 单文件承载硬/软两套逻辑、`common.js` 里纯字符串拼 HTML,随迭代变重且难测。 +- 建议:按"页面级 .js + 复用组件(表格/弹窗/分页/完整度进度条)拆为公共函数";引入轻量模板(如简版 render helper)减少内联字符串拼接。暂不建议一步上 Vue/React(成本高、与现有纯 PHP 交付模式冲突),先做 JS 模块化。 + +**[P2] 数据库与查询优化** +- 现状:`social_accounts` 多态 owner,跨表统计/归属口径偏绕;`preliminary_data` 与主表存在冗余转换标记(converted_id)之外还可能有口径不一致。 +- 建议:为高频过滤列(`is_incomplete`、`source_channel`、`owner_type+owner_id`、`platform`)建合适索引;`data_dict_*` 与下拉统一走 `dicts.php`,避免前端硬编码选项。 +- `phone.dat`(4.5MB)已内置——可做成手机号归属地离线查询服务,作为特色能力,无需依赖外部 API。 + +**[P2] 可观测与运维** +- 建议:`system_logs` 已有审计,再补一版 `error_log` 拦截与慢查询记录;为 EDM/渠道数据出 CSV 导出统一封装。 + +### 4.2 产品功能层面(对齐官网承诺 + 竞争差异化,按收益排序) + +**[P0] AI 补全 落地(破局点,官网已占位)** +- 现状:硬/软数据操作列的"AI 补全"是**占位**按钮。 +- 建议:接通千视的 RAG/大模型能力,基于碎片已有线索(名称/行业/电话/IP归属/社媒)自动推断补全(企业行业、人员城市、产品品类、缺失的国家/地址),生成**带置信度的建议值**,用户一键采纳/拒绝。这是把"AI 拓客"从口号变实能力的第一步,也是最能拉开与手工录入竞品差距的点。 + +**[P0] 触达链路(EDM/企微/短信)落地** +- 现状:只有 `edm.php` 做筛选+导出,无真实触达。 +- 建议:把 `social_accounts` 中的 email/phone 作为触点,接 EDM 发送与企微/短信模板,支持"AI 生成个性化触达文案 + 定时批量发送 + 打开/回复回传",并回写跟进记录 → 打通官网承诺的"智能触达链路"。 + +**[P1] 线索自动流转 + 跟进轨迹(销售闭环)** +- 现状:无"商机/跟进状态"概念。 +- 建议:为"线索(lead)→商机(opportunity)→成交"增加状态机,识别到意向自动标记并推送销售人员(关联 `system_users`),`system_logs` 扩展为完整跟进轨迹时间线。 + +**[P1] ROI 数据面板** +- 现状:dashboard 有统计卡,但无获客成本/转化率/成交额 ROI 视图。 +- 建议:在 dashboard 增加"渠道 ROI":每渠道的线索成本、到商机转化率、预估成交额,用 ECharts 呈现(代码已引入 echarts),直接兑现官网"ROI 可视化"。 + +**[P1] EAV 产品参数(已建模)完善 + 行业对比** +- 现状:`company_products_attr/value` EAV 已建,需求文档提及"行转列做行业对比",但未见落地页。 +- 建议:做一个"同行业竞争对手产品参数对比"视图,既充实竞品管理板块,又是 B2B 客户的差异化卖点。 + +**[P1] 竞品分析 / 媒体舆情的真实实现** +- 现状:`media_opinion / competitor_analysis / competitor_data` 多为占位。 +- 建议:最优先实现"舆情监测"(结合千视舆情能力)+ "竞品资料库"(结构化工/人员/产品/社媒/文档),做成官网可展示的收费模块,增强获客。 + +**[P2] 人脉亲密度(已有的独特功能)产品化放大** +- 现状:`person/connections.php` 老乡/校友/同事亲密度算法已实现,是差异化亮点。 +- 建议:把"亲密度 + N度人脉"做成可视化人脉图谱,作为 B2B 客户拓展的亮点展示,强化"中国 B2B 语义"壁垒。 + +**[P2] 数据导入质量提升** +- 现状:CSV import/export 已存在,但导入后碎片如何处理无引导。 +- 建议:导入不完整的数据自动进入"软碎片"队列,走完整度引擎标级,让"导入即治理"形成闭环。 + +### 4.3 建议路线图(2-3 个迭代周期) + +| 优先级 | 周期 | 主题 | 关键交付 | +|---|---|---|---| +| P0 | 第 1 个周期 | **AI 能力闭环落地** | ✓迁移版本管理 ✓AI补全(接千视RAG) ✓EDM真实触达 ✓ROI面板 | +| P1 | 第 2 个周期 | **销售闭环 + 差异化模块** | ✓线索状态机/自动流转 ✓跟进轨迹 ✓舆情监测 ✓竞品参数对比 ✓人脉图谱 | +| P2 | 持续迭代 | **架构深化 + 打磨** | ✓前端组件化 ✓鉴权/安全加固 ✓索引/性能 ✓导入即治理 ✓私有化交付模板化 | + +### 4.4 一句话收尾 + +> SuperLink 目前的**真实竞争力在"数据治理方法论 + 中国 B2B 语义 + 可私有化定制"**,护城河根基已立;但要兑现官网"AI 驱动精准触达、帮助获客销售"的承诺,**必须优先把 AI 补全、多渠道触达、线索流转、ROI 面板四个闭环在代码里落地**——这既是竞争短板,也是千视"AI 增长引擎"卖点的最大机会。 \ No newline at end of file diff --git a/docs/superlink_架构与功能优化设计方案.md b/docs/superlink_架构与功能优化设计方案.md new file mode 100644 index 0000000..2144bf7 --- /dev/null +++ b/docs/superlink_架构与功能优化设计方案.md @@ -0,0 +1,235 @@ +# SuperLink 架构与功能优化设计方案(草案 v0.1 · 待评审) + +> 原则:**先文档 · 后评审 · 再写代码**。 +> 本文档为实施前的设计草案 + 现状评审发现,供项目负责人评审拍板。评审通过前不改动代码。 +> 关联:`docs/superlink_产品评测与优化路线图_2026.md`(战略总览);本文档为落地执行细案。 + +- 撰写:2026-09 +- 目标版本:v1.0.51 起 +- 状态:**DRAFT — 待评审** + +--- + +## 0. 评审结论速览(TL;DR) + +| 主题 | 结论 | 建议动作 | +|---|---|---| +| 迁移管理 | ⚠️ 无版本表,多环境易漏跑迁移 | 第一批落地 | +| 鉴权 | 🔴 login 用 `sha1`(无盐),CSRF 仅靠请求头 | 第一批加固 | +| 样板代码 | 🔴 每个接口重复堆"鉴权+校验+JSON+日志" | 抽出统一定义层 | +| 前端 | 🟡 单文件过大、字符串拼 HTML | 组件化收敛 | +| 官网承诺 vs 代码 | 🔴 AI 触达/线索流转/ROI 是营销态,代码未实现 | 第二批功能闭环 | +| 差异化 | 🟢 碎片治理/完整度/渠道归因已是独特资产 | 放大 + 产品化 | +| 数据库 | 🟡 索引/口径/预算备份表待清理 | 第三批打磨 | + +> 🔴=高优先级 · 🟡=中 · 🟢=已具备/优 + +--- + +## 1. 现状评审发现(问题清单) + +> 依据源码精读(文件行号截至 v1.0.50)。 + +### 1.1 安全 +- **[REV-SEC-1]🔴 密码仅 `sha1` 无加盐**(`api/auth/login.php:37` `hash_equals($user['password'], sha1($password))`)。撞库成本极低。 +- **[REV-SEC-2]🟡 CSRF 仅靠 `X-Requested-With` 头**(`common/auth.php::checkAjax`),无真正的 CSRF Token,敏感写操作可被跨站伪造(头只能防"简单表单")。 +- **[REV-SEC-3]🟡 登录无速率限制/失败锁定**,可被爆破。 +- **[REV-SEC-4]🟡 `session` 默认配置**,未设 cookie httponly/samesite,未设会话超时/固定防护。 + +### 1.2 可维护性 / 架构 +- **[REV-ARCH-1]🔴 无数据库迁移版本表**。`sql/migration_v1.0.XX.sql` 需手工依次执行,无 `schema_versions` 记录、无校验、无回滚;v1.0.48 遗留 `preliminary_data_bak_*` 冗余备份表。 +- **[REV-ARCH-2]🔴 样板重复**:每个 `api/<模块>/<动作>.php` 重复 `require db/response/auth + checkAjax + checkPermission + SQL + logAction + Response`,约十余个接口高度同构,改一处规范需改 N 处。 +- **[REV-ARCH-3]🟡 无集中错误/慢查询观测**,仅靠 `system_logs` 业务审计,无技术异常日志。 +- **[REV-ARCH-4]🟡 前端 `static/js/fragment.js` 单文件承载硬/软两套逻辑,`common.js` 用字符串拼 HTML,难维护难测试。 +- **[REV-ARCH-5]🟢 统一 JSON 规范(`code/msg/data`)、字段白名单(`helpers.php`)、完整度引擎(`completeness.php`)、审计(`logger.php`)已具备且质量良好**——是很好的可扩展地基。 + +### 1.3 数据 / 性能 +- **[REV-DATA-1]🟡 `social_accounts` 多态 owner,跨表统计口径绕**;高频过滤列(`is_incomplete`/`source_channel`/`owner_type+owner_id`/`platform`)索引不明确。 +- **[REV-DATA-2]🟡 `ammer_time` 历史遗留:`tools/migrate_time_to_date.php` 已有迁移脚本,但未见在迁移流程中统一执行。 +- **[REV-DATA-3]🟢 `phone.dat`(4.5MB)已打包,可做成离线手机归属地查询能力,无外部依赖。 + +### 1.4 功能缺口(对照官网承诺) +- **[REV-FN-1]🔴 "AI 补全" 是占位**(`fragment.js` 操作列),未真正调用任何 AI/RAG。 +- **[REV-FN-2]🔴 EDM 只有"筛选+导出"**(`marketing/edm.php`),无真实发送/回传。 +- **[REV-FN-3]🔴 无"线索→商机→成交"状态机与跟进轨迹时间线**,与官网"线索自动流转、全程记录跟进轨迹"不符。 +- **[REV-FN-4]🟡 无 ROI 面板**(dashboard 有统计卡,无"获客成本/转化率/成交额")。 +- **[REV-FN-5]🟡 舆情监测/竞品分析多为占位**;EAV 产品参数已建模但"行业对比"视图未落地。 +- **[REV-FN-6]🟢 人脉亲密度算法已实现**(`person/connections.php`),可产品化为可视化图谱放大差异化。 + +### 1.5 代码级评审发现(已核实 v1.0.50 · 2026-09 补充 code review) + +> 由评审会对 `common/ auth/ fragment/ channel/ system/user_add` 逐一核实到行号得出。 + +**🔴 严重(安全)** +- **[REV-1]** 密码 `sha1` 无加盐:`login.php:37`、`user_add.php:48`。离线可撞库。 +- **[REV-2]** CSRF 仅 `X-Requested-With` 头(`auth.php::checkAjax`),无随机 Token。 +- **[REV-3]** 前端字符串拼 `innerHTML` 渲染服务器字段 → **潜在存储型 XSS(待确认 C1)**,缺统一转义。 + +**🟠 高(一致性/正确性)** +- **[REV-4]** 硬碎片来源不可编辑:`hard_update.php:89-111` 的 UPDATE 装列不含 `source_channel`,只能 add 不能改。 +- **[REV-5]** 碎片录入被主表全局唯一性"拦死"(`hard_add/soft_update` 对 `companies/social_accounts` 判重):**业务口径需确认 C2**。同邮箱/电话跨企业、多电话同人时新线索录不进。 +- **[REV-6]** `soft_update.php:77` `$id` 以字符串拼进 SQL(已 int 强转不可注入,但破坏 prepared 风格,卫生项)。 + +**🟡 中** +- **[REV-7]** 渠道分析 N+1:`analysis.php:59-69` 循环内逐条查 source_detail。 +- **[REV-8]** 登录无限流/无失败锁定、`startSession` 未设 httponly/samesite。 +- **[REV-9]** `analysis.php` 表名 `$table` 直接拼 SQL(当前仅内部常量调用安全,属 footgun)。 + +**🔵 低** +- **[REV-10]** `hard_convert.php:141` `$resolveCompany` 先查后插,并发同名称可能建重复企业。 +- **[REV-11]** `user_add.php:23` 密码仅长度校验。 +- **[REV-12]** phone/email 全库跨企业+人员全局判重,交叉占用会互相阻断(同 REV-5)。 + +**待确认项** +- **C1** 前端渲染是否逐处转义(决定 REV-3 是否为已存在 XSS)。 +- **C2** 全局唯一性是"阻断"还是"提示"(决定碎片录入产品语义,涉及 REV-5/REV-12)。 + +--- + +## 2. 目标架构设计 + +### 2.1 目录结构(目标) + +``` +api/ + common/ + Api.php # 新增:统一接口基座(鉴权 + 参数 + JSON + 日志 + 分页) + migrate.php # 新增:迁移执行器(配合 sql/schema_versions) + security.php # 新增:CSRF Token、限流、Session 安全配置 + auth/ channel/ ... # 业务模块(逐步接入 Api.php,非一次性重写) +tools/ + migrate.php # 新增/改造:命令行跑迁移 + bump_version.php # 改造:同时同步 config.js +sql/ + schema_versions.sql # 新增:建版本表 + 首次基线 +``` + +### 2.2 评审通过的决策点(由你拍板) + +- **D1 迁移方案**:采用「版本表 + 顺序执行脚本」最小方案(A),暂不引入 php-migrations 等第三方依赖,保持纯 PHP 极简。 +- **D2 密码升级**:`password_hash(password_verify)`,存量 sha1 用户在登录命中时平滑迁移到新哈希(登录一次即升级,无需批量重写密码)。 +- **D3 CSRF**:Session 绑定随机 Token,前端 `common.js` AJAX 统一追加 `X-CSRF-Token` 头;保持现有 `checkAjax()` 兼容。 +- **D4 是否抽统一 `Api.php` 基座**:建议抽;新接口必须走它,存量接口"用到的先迁、不动的不强制迁移",避免大爆炸式重写。 +- **D5 AI 补全如何接入**:接千视 RAG/大模型(需提供 API),输出"带置信度的建议字段",用户一键采纳/拒绝。**若暂无 AI API,可先做"规则式自动补全"(手机归属地→城市、行业字号→行业、国家→地址缺省)占位过渡。** +- **D6 触达通道**:EDM 发送优先(可邮件模板 + SMTP/第三方),企微/短信二期。 + +### 2.3 统一接口基座(`common/Api.php` 设计) + +```php +class Api { + // 用法:Api::handle('fragment', function(PDO $pdo, array $in){ ... return $data; }) + // 内部统一:requireLogin + checkPermission + checkAjax(写) + 参数默认 + Response + 异常兜底(log) + logAction +} +``` +- 收敛 `checkAjax/checkPermission/logAction` 重复样板; +- 统一异常→JSON、SQL 错误→技术日志 + 友好提示; +- 提供 `paged()` 输出,统一 list 分页结构。 + +### 2.4 迁移执行器(`tools/migrate.php` + `sql/schema_versions`) + +- 建 `schema_versions(id, version, applied_at, checksum)`; +- `php tools/migrate.php up` 顺序执行 `sql/migration_*.sql` 中未应用者,记录版本与文件 checksum(防改旧脚本); +- 提供 `php tools/migrate.php down `(可选,先支持正向即可); +- 首次基线收录现有 migration_*.sql;清理 `preliminary_data_bak_*` 用一条收敛迁移。 + +--- + +## 3. 功能设计(第二批) + +### 3.1 AI/规则补全(P0,破局) +- 数据表:无需新表,落在 `preliminary_data` / 主表字段。 +- 接口:`api/fragment/ai_suggest.php` + - 入参:`target_type / fragment_id / 已有字段` + - 出参:`suggestions:[{field, value, confidence, reason}]` +- 前端:硬/软碎片"AI 补全"按钮 → 弹窗展示建议列表 → 采纳/拒绝 → 回写 `hard_update/soft_update`。 +- 过渡:权限/hook 式——**有千视 API 走 AI,无则走规则引擎**(手机归属地→工作地/国家;`cn_to_en` 已有能力复用)。 + +### 3.2 EDM 真实触达 + ROI 面板(P0/P1) +- EDM:`edm.php` 增 `发送/模板/任务`,`edm_export` 保持;发送回执写回 `system_logs` 或新 `edm_tasks`。 +- ROI:dashboard 增加"渠道 ROI"卡片/图表(数据来自 `source_channel` × 商机状态 × 预估成交额),复用已引入的 ECharts。 + +### 3.3 线索状态机 + 跟进轨迹(P1) +- 新表 `leads(id, ref_table, ref_id, status[初访/跟进中/已商机/已成交/放弃], owner_user_id, pipeline...)`,或直接在 `companies/persons` 加 `lead_status`。 +- `system_logs` 扩展 `follow_status`,形成按线索的时间线。 +- 意向识别(`is_incomplete` 由 1→0、完整度晋升、EDM 打开/回复)→ 自动创建 lead 并推送 `owner_user_id`。 + +### 3.4 舆情监测 + 竞品参数对比 + 人脉图谱(P1/P2,差异化) +- 舆情:接入千视舆情能力 → `media_opinion` 真实成单页。 +- 竞品对比:EAV 行转列,同行业 `company_products_attr_value` 对比表 → `competitor_data` 落地。 +- 人脉图谱:`person/connections.php` 亲密度 → ECharts 关系图(graph)→ `person` 详情页增强。 + +--- + +## 4. 落地路线图(3 个批次) + +> 每批独立可发布、可评审、可回滚。 + +### 批次 1 · 架构筑基(v1.0.51) +| 项 | 涉及文件 | 说明 | +|---|---|---| +| 迁移版本管理 | 新增 `tools/migrate.php`、`sql/schema_versions.sql`;收敛旧 migration | 先建基座 | +| 登录安全 | `login.php`(bcrypt 平滑迁移)、`auth.php`、新增 `security.php` | bcrypt + 限流 + session 加固(REV-1/REV-8) | +| CSRF Token | `auth/auth.php`/`session.php` + `common.js` | 写接口头校验(REV-2) | +| 统一 Api 基座 | 新增 `common/Api.php`;先迁 `fragment/hard_*` 或 `system/*` 一个模块试点 | 收敛样板 | +| 前端收敛 | `common.js` 拆表格/弹窗/分页 helper + 统一 `esc()` 转义 | 可增量(REV-3/C1) | +| 碎片来源可编辑 | `fragment/hard_update.php` 更新装列加入 `source_channel` | 一致性(REV-4) | +| prepared 卫生 | `soft_update.php:77` 等残余字符串拼参改占位符 | 卫生项(REV-6/REV-9) | + +**验收**:①能 `up`/`down` 跑迁移;②新用户密码为 bcrypt、老用户登录后被平滑升级;③写接口带 CSRF Token 合法可过、缺失被拒;④至少 1 个模块接入 Api 基座且行为不变;⑤硬碎片来源可新增/修改;⑥统一 `esc()` 后抽查模块无未转义渲染(此需求取决于 C1 确认)。 + +### 批次 2 · 功能闭环(v1.0.52) +| 项 | 说明 | +|---|---| +| AI/规则补全落地 | `ai_suggest.php` + 前端弹窗(接千视 API 或规则兜底) | +| EDM 真实触达 | 模板/发送/回执 | +| ROI 面板 | dashboard 渠道 ROI 图表 | + +### 批次 3 · 销售闭环 + 差异化(v1.0.53+) +| 项 | 说明 | +|---|---| +| 线索状态机 + 跟进时间线 | `leads` 表 + 自动流转 | +| 舆情监测 / 竞品参数对比 / 人脉图谱 | 差异化成单模块 | +| 索引 / 性能 / 导入即治理 | 打磨 | + +--- + +## 5. 需要你拍板的决策(评审门槛) + +1. **D5 关键**:AI 补全——是否已有千视 RAG/大模型 API 可接?若暂缺,**是否接受先用"规则式补全"过渡**?(决定批次 2 的 AI 项怎么落地) +2. **D4**:是否同意新增 `common/Api.php` 统一基座并"用到的先迁、存量不强制"? +3. **D2**:密码升级方案(bcrypt 平滑迁移)是否认可?(会改变存量用户密码存储格式) +4. **批次节奏**:是否按「批次1 架构筑基 → 批次2 功能闭环 → 批次3 差异化」推进?可只做其中某批次。 +5. 优先级冲突时:**先保架构地基(批次1)还是先上能对外演示的功能(批次2)?** +6. **C2(评审 REV-5/12)**:手机/邮箱/企业名等**全局唯一性校验**遇到主表已有记录时,是保持"**阻断**新增"(当前行为)还是降级为"**提示但允许继续**"?(后者更贴合"碎片=待审线索"语义,但会让同一电话挂在多个主体下) + +> 附:C1(前端转义是否已存在 XSS)需现场确认后再决定批次1「前端收敛」是否纳入 REV-3 修复。 + +> 评审意见请直接回复上表的决策项编号与结论(例如"D5 暂无AI,先规则补全;按批次1→2→3 推进")。通过后我按批次开始写代码。 + +--- + +## 6. 批次1 实现记录(v1.0.51 · 已实现并验证) + +已拍板的批次1范围,本版本全部落地。服务器端验证通道:`ssh ubuntu@106.55.169.92` → docker `php:8.2-cli php -l`(95/95 通过)+ CSRF/bcrypt CLI 冒烟(ALL PASS)。 + +| 项 | 落地文件 | 验证 | +| --- | --- | --- | +| ①版本迁移 | `sql/schema_versions.sql`、`sql/migration_v1.0.51.sql`、`tools/migrate.php`(`up/status/down`、natsort、md5 checksum) | lint 通过 | +| ②登录安全 | bcrypt 平滑迁移(`api/auth/login.php`:旧 sha1 命中→`password_verify`→自动重写 bcrypt;`user_add/user_update` 新强度规则+`password_hash`)+ 限流/session 加固(`api/common/security.php`) | 冒烟 PASS | +| ③CSRF Token | 服务端 `csrfToken()/checkCsrf()`(`security.php`),GET 取 token(`auth/csrf.php` public)、写接口统一校验;前端 `common.js` httpPost 携带 `X-CSRF-Token`、`login.js` 登录页取 token | 冒烟 PASS(错误 token → 403) | +| ④统一基座+强制迁移 | `common/Api.php` `Api::boot(['public'/'super'/'module'/'permissions'])`,**全部 70 个 endpoint 存量强制迁移**(脚本转换,逐一审计无遗漏、无误删 include);`hard_common.php` 等纯 include 保留 | 全局 require 审计干净 | +| ⑤前端收敛 | `common.js` 新增 `esc` 别名(现即统一转义入口);统一 `escHtml` | — | +| ⑥REV-4 来源渠道可编辑 | `hard_update.php` 支持 `source_channel` + 前端补全弹窗新增来源渠道下拉(回显) | — | +| ⑦prepared 卫生 | `soft_update.php` 违规 `$pdo->query` 修为 prepared(REV-6);`analysis.php` 表名白名单(REV-9) | lint 通过 | + +**明确延后(记录在案,建议下批次处理)**: +- REV-7:`analysis.php` 渠道详情 N+1(`LIMIT 10` 内子查询,量级可控,批内不动)。 +- REV-3/C1:XSS 前端收敛——已提供统一 `esc()` 转义入口,但存量页面 JS 的逐个改逃逸需下批次;新写代码一律用 `esc()`。 +- REV-5/12:手机/邮箱/企业名全局唯一性 **保持"阻断新增"**(已拍板)。 +- REV-10:并发下企业重名竞态(可在 `company/add` 加唯一索引兜底,下批次)。 +- 部署形态:确认长期部署目标为服务器 106.55.169.92;`config/database.php` 当前写死 `127.0.0.1/mysuperlink/...`,容器化部署需改为 env 注入(下批次随 docker-compose 落地)。 + +--- + +## 附录 A:与战略文档的关系 +本《设计方案》是把战略文档第四部分(架构+功能优化)细化到"改哪些文件、怎么改、能否回滚、验收标准"的可执行层。两文档共用同一结论:**先补齐迁移/安全/样板三块地基,再用 AI 补全 + EDM + 线索流转 + ROI 兑现官网承诺,最后用舆情/竞品/人脉图谱做出差异化。** \ No newline at end of file diff --git a/sql/migration_v1.0.51.sql b/sql/migration_v1.0.51.sql new file mode 100644 index 0000000..bf3d446 --- /dev/null +++ b/sql/migration_v1.0.51.sql @@ -0,0 +1,19 @@ +-- ============================================================ +-- v1.0.51 批次1「架构筑基」迁移 +-- 1) 登录限流表(security.php 的 checkLoginThrottle/recordLoginAttempt 依赖) +-- 2) 清理 v1.0.48 遗留的 preliminary_data 冗余备份表 +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `system_login_attempts` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `username` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '登录账号', + `ip` VARCHAR(45) NOT NULL DEFAULT '' COMMENT '客户端IP(IPv6最多45字符)', + `success` TINYINT NOT NULL DEFAULT 0 COMMENT '1=成功 0=失败', + `attempt_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_ip_user_time` (`ip`, `username`, `attempt_time`), + KEY `idx_attempt_time` (`attempt_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='登录尝试记录(限流/防爆破)'; + +-- 清理 v1.0.48 遗留冗余备份表(架构评审 REV-ARCH-1) +DROP TABLE IF EXISTS `preliminary_data_bak_20260810b`; diff --git a/sql/schema_versions.sql b/sql/schema_versions.sql new file mode 100644 index 0000000..a8d99a7 --- /dev/null +++ b/sql/schema_versions.sql @@ -0,0 +1,12 @@ +-- ============================================================ +-- schema_versions 迁移版本表(批次1 迁移管理基座) +-- 由 tools/migrate.php 首次引导建表;记录每次已应用迁移的版本与文件 checksum +-- ============================================================ +CREATE TABLE IF NOT EXISTS `schema_versions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `version` VARCHAR(64) NOT NULL COMMENT '迁移版本号,如 v1.0.51', + `applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `checksum` CHAR(32) NOT NULL COMMENT '迁移文件 md5,防旧脚本被改', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_version` (`version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库迁移版本表'; diff --git a/static/js/common.js b/static/js/common.js index 9e2a437..ab6d70e 100644 --- a/static/js/common.js +++ b/static/js/common.js @@ -28,7 +28,7 @@ function httpPost(url, data, isFormData) { url: BASE_URL + url, method: 'POST', dataType: 'json', - headers: { 'X-Requested-With': 'XMLHttpRequest' } + headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-Token': window.CSRF_TOKEN || '' } }; if (isFormData) { opts.data = data; @@ -128,6 +128,11 @@ function escHtml(str) { .replace(/"/g, '"').replace(/'/g, '''); } +/** esc 别名:与后端 esc() 同名,前端渲染动态内容统一转义 */ +function esc(str) { + return escHtml(str); +} + /** 手机号清洗:清除所有空白(含数字中间的空格),如 "138 1234 5678" -> "13812345678" */ function cleanPhone(v) { return String(v == null ? '' : v).replace(/\s+/g, ''); @@ -169,6 +174,7 @@ function checkPagePermission(key) { function initPage(activeKey, cb) { httpGet('auth/session.php').then(function (user) { window.CURRENT_USER = user; + window.CSRF_TOKEN = user.csrf_token || ''; // 写接口统一携带 X-CSRF-Token try { renderMenu(activeKey, user.permissions || []); diff --git a/static/js/config.js b/static/js/config.js index 715db34..282e812 100644 --- a/static/js/config.js +++ b/static/js/config.js @@ -5,7 +5,7 @@ var BASE_URL = '/api/'; var PAGE_SIZE = 20; /** 系统版本号(logo旁展示):修改代码后运行 tools/bump_version.php 自动递增 */ -var APP_VERSION = 'v1.0.50'; +var APP_VERSION = 'v1.0.51'; /** 页脚版权/备案信息(在 config.js 中修改) */ var FOOTER_TEXT = '© 2026 SuperLink 管理系统 版权所有 | 备案号:请替换为真实备案号'; diff --git a/static/js/fragment.js b/static/js/fragment.js index 5d27d27..96d9d4a 100644 --- a/static/js/fragment.js +++ b/static/js/fragment.js @@ -333,6 +333,7 @@ $(function () { '
' + '
' + '
' + typeSel + '
' + + '
' + '
' + '
' + '
' + @@ -363,6 +364,14 @@ $(function () { btn: false }); $('#cv-platform').val(d.platform || ''); + // 来源渠道选项(与 source_channels 字典保持一致),并回显当前值 + httpGet('common/dicts.php', { type: 'all' }).then(function (dd) { + var html = ''; + (dd.source_channels || []).forEach(function (c) { + html += ''; + }); + $('#cv-source').html(html).val(d.source_channel || ''); + }).catch(function () {}); function syncFields() { var t = $('#cv-type').val(); diff --git a/static/js/login.js b/static/js/login.js index fbe5f3a..4e18bef 100644 --- a/static/js/login.js +++ b/static/js/login.js @@ -8,6 +8,14 @@ $(function () { $('#login-btn').prop('disabled', false); }); + // 未登录场景:先从 auth/csrf.php 取会话绑定的 CSRF Token,写接口统一携带 + window.CSRF_TOKEN = ''; + $.getJSON(BASE_URL + 'auth/csrf.php').done(function (resp) { + if (resp && resp.code === 0 && resp.data && resp.data.csrf_token) { + window.CSRF_TOKEN = resp.data.csrf_token; + } + }); + // 回车提交 $('#password').on('keydown', function (e) { if (e.keyCode === 13) doLogin(); @@ -15,6 +23,10 @@ $(function () { $('#login-btn').on('click', doLogin); + function csrfHeaders() { + return { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-Token': window.CSRF_TOKEN || '' }; + } + function doLogin() { var username = $.trim($('#username').val()); var password = $('#password').val(); @@ -27,7 +39,7 @@ $(function () { url: BASE_URL + 'auth/login.php', method: 'POST', dataType: 'json', - headers: { 'X-Requested-With': 'XMLHttpRequest' }, + headers: csrfHeaders(), data: { username: username, password: password, slider_token: sliderToken } }).done(function (resp) { if (resp.code === 0) { @@ -80,7 +92,7 @@ $(function () { url: BASE_URL + 'auth/forgot.php', method: 'POST', dataType: 'json', - headers: { 'X-Requested-With': 'XMLHttpRequest' }, + headers: csrfHeaders(), data: { username: username, contact_email: email } }).done(function (resp) { var color = resp.code === 0 ? '#27ae60;background:#eafaf1;border:1px solid #a9dfbf' : '#c0392b;background:#fdecea;border:1px solid #f5c6cb'; diff --git a/tools/migrate.php b/tools/migrate.php new file mode 100644 index 0000000..552e8fd --- /dev/null +++ b/tools/migrate.php @@ -0,0 +1,136 @@ + # 移除 v<=version 的版本记录(仅记录,SQL 不自动回滚) + * + * 规则: + * - 迁移文件命名 sql/migration_v1.0.XX.sql,按版本号升序执行(natsort) + * - 版本表 schema_versions 由 sql/schema_versions.sql 引导建表 + * - 每个迁移记录 version + 文件 md5(checksum),已应用文件被改动会告警并跳过 + * - 只支持正向迁移(down 仅为运维便利,SQL 变更需手工回滚) + */ + +// 命令行运行保护 +if (PHP_SAPI !== 'cli') { + fwrite(STDERR, "[错误] 请通过命令行运行:php tools/migrate.php\n"); + exit(1); +} + +$cfg = require __DIR__ . '/../config/database.php'; +$dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=%s', $cfg['host'], $cfg['port'], $cfg['dbname'], $cfg['charset']); +try { + $pdo = new PDO($dsn, $cfg['username'], $cfg['password'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); +} catch (PDOException $e) { + fwrite(STDERR, "[错误] 数据库连接失败:{$e->getMessage()}\n"); + exit(1); +} + +$sqlDir = __DIR__ . '/../sql'; +$schemaSql = file_get_contents($sqlDir . '/schema_versions.sql'); + +/** 建版本表(不存在才建) */ +function ensureSchemaTable(PDO $pdo, $schemaSql) +{ + try { + $pdo->query("SELECT COUNT(*) FROM schema_versions"); + } catch (PDOException $e) { + $pdo->exec($schemaSql); + echo "[初始化] 已创建 schema_versions 表\n"; + } +} + +/** 读取已应用记录:version => checksum */ +function appliedVersions(PDO $pdo) +{ + $map = []; + foreach ($pdo->query("SELECT version, checksum FROM schema_versions")->fetchAll() as $r) { + $map[$r['version']] = $r['checksum']; + } + return $map; +} + +/** 扫描迁移文件:返回 [version => absolutePath] 升序 */ +function migrationFiles($sqlDir) +{ + $files = glob($sqlDir . '/migration_*.sql'); + natsort($files); + $map = []; + foreach ($files as $f) { + $base = basename($f, '.sql'); // migration_v1.0.51 + $ver = substr($base, strlen('migration_')); // v1.0.51 + $map[$ver] = $f; + } + return $map; +} + +$action = $argv[1] ?? 'up'; + +switch ($action) { + case 'up': + ensureSchemaTable($pdo, $schemaSql); + $applied = appliedVersions($pdo); + $files = migrationFiles($sqlDir); + $pending = 0; + foreach ($files as $ver => $path) { + $sum = md5_file($path); + if (isset($applied[$ver])) { + if ($applied[$ver] !== $sum) { + fwrite(STDERR, "[警告] {$ver} 已应用但文件被改动(checksum 不一致),已跳过。如需重跑请先手动清理 schema_versions。\n"); + } + continue; + } + $pending++; + echo "[迁移] 应用 {$ver} ... "; + try { + $pdo->exec(file_get_contents($path)); + $ins = $pdo->prepare("INSERT INTO schema_versions (version, applied_at, checksum) VALUES (?, NOW(), ?)"); + $ins->execute([$ver, $sum]); + echo "OK\n"; + } catch (PDOException $e) { + fwrite(STDERR, "失败:{$e->getMessage()}\n"); + exit(1); + } + } + if ($pending === 0) { + echo "数据库已是最新版本(共 " . count($applied) . " 个迁移)。\n"; + } else { + echo "完成,本次应用 {$pending} 个迁移。\n"; + } + break; + + case 'status': + ensureSchemaTable($pdo, $schemaSql); + $applied = appliedVersions($pdo); + $files = migrationFiles($sqlDir); + echo str_pad('版本', 12) . str_pad('状态', 10) . "文件\n"; + echo str_repeat('-', 60) . "\n"; + foreach ($files as $ver => $path) { + $state = isset($applied[$ver]) ? '已应用' : '待应用'; + printf("%-12s %-10s %s\n", $ver, $state, basename($path)); + } + break; + + case 'down': + $target = $argv[2] ?? ''; + if ($target === '') { + fwrite(STDERR, "用法:php tools/migrate.php down \n"); + exit(1); + } + ensureSchemaTable($pdo, $schemaSql); + $del = $pdo->prepare("DELETE FROM schema_versions WHERE version >= ?"); + $del->execute([$target]); + echo "已移除 version >= {$target} 的迁移记录(SQL 变更不会自动回滚,请自行处理)。\n"; + break; + + default: + fwrite(STDERR, "未知命令:{$action}(支持 up / status / down )\n"); + exit(1); +}