v1.0.51: 批次1架构筑基 - 1)版本迁移(common/migrate.php+schema_versions+natsort迁移文件);2)登录安全(bcrypt平滑迁移login/user_add/user_update旧sha1命中自动重写+system_login_attempts限流+session加固httponly/samesite/secure);3)CSRF Token(服务端checkCsrf+auth/csrf.php登录页取token+前端common.js/login.js统一携带X-CSRF-Token);4)统一基座common/Api.php并存量强制迁移全部70个endpoint(Api::boot按public/super/module/permissions分流,写接口强制checkAjax+checkCsrf,全局异常处理);5)前端收敛(common.js新增esc转义别名);6)REV-4硬数据来源渠道可编辑(hard_update+补全弹窗下拉);7)prepared卫生(soft_update.php修复+analysis.php表名白名单REV-9)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
/**
|
||||
* 获取 CSRF Token GET /api/auth/csrf.php
|
||||
* 供登录页等未登录场景在发起 POST 前先取会话绑定的 Token。
|
||||
* 登录后 session.php 会随用户信息一并返回 csrf_token(common.js 自动缓存)。
|
||||
*/
|
||||
require_once __DIR__ . '/../common/Api.php';
|
||||
$pdo = Api::boot(['public' => true]);
|
||||
|
||||
Response::success(['csrf_token' => csrfToken()]);
|
||||
+3
-6
@@ -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'] ?? '');
|
||||
|
||||
+32
-13
@@ -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(),
|
||||
], '登录成功');
|
||||
|
||||
+3
-7
@@ -1,15 +1,11 @@
|
||||
<?php
|
||||
/**
|
||||
* 登出接口 POST /api/auth/logout.php
|
||||
* 批次1 起经 Api::boot 统一校验登录态 + Ajax + CSRF。
|
||||
*/
|
||||
require_once __DIR__ . '/../common/db.php';
|
||||
require_once __DIR__ . '/../common/response.php';
|
||||
require_once __DIR__ . '/../common/logger.php';
|
||||
require_once __DIR__ . '/../common/auth.php';
|
||||
require_once __DIR__ . '/../common/Api.php';
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
Api::boot();
|
||||
|
||||
if (!empty($_SESSION['user_id'])) {
|
||||
logAction((int)$_SESSION['user_id'], $_SESSION['username'] ?? '', 'logout', 'auth', 'system_users', (int)$_SESSION['user_id']);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<?php
|
||||
/**
|
||||
* 会话校验接口 GET /api/auth/session.php
|
||||
* 前端页面加载时调用:未登录返回 401(前端跳转登录页),已登录返回当前用户信息。
|
||||
* 前端页面加载时调用:未登录返回 401(前端跳转登录页),已登录返回当前用户信息 + CSRF Token。
|
||||
*/
|
||||
require_once __DIR__ . '/../common/response.php';
|
||||
require_once __DIR__ . '/../common/auth.php';
|
||||
require_once __DIR__ . '/../common/Api.php';
|
||||
|
||||
requireLogin();
|
||||
Api::boot(); // 仅要求登录
|
||||
|
||||
$pdo = DB::getInstance()->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,
|
||||
]);
|
||||
|
||||
@@ -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 !== '') {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
/**
|
||||
* 统一接口基座 v1(批次1「架构筑基」REV-ARCH-2)
|
||||
*
|
||||
* 所有业务接口统一走 Api::boot(),收敛「require 一串公共文件 + checkAjax +
|
||||
* checkPermission + DB 连接」的重复样板,并提供统一的异常→JSON 兜底。
|
||||
*
|
||||
* 用法(endpoint 顶部):
|
||||
* require_once __DIR__ . '/../common/Api.php';
|
||||
* $pdo = Api::boot(['module' => '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();
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -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 头 */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 === '') {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
/**
|
||||
* 安全中间件(批次1):Session 加固 + CSRF Token + 登录限流
|
||||
* 依赖:db.php / response.php / logger.php(均无回环依赖)
|
||||
*/
|
||||
require_once __DIR__ . '/db.php';
|
||||
require_once __DIR__ . '/response.php';
|
||||
require_once __DIR__ . '/logger.php';
|
||||
|
||||
/** 安全启动 Session:httponly + samesite=Lax + HTTPS 下 secure */
|
||||
function startSessionSecure()
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 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()]);
|
||||
}
|
||||
}
|
||||
+2
-9
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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 === '') {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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, '已保存,点击「转软碎片」完成转换');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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')";
|
||||
|
||||
@@ -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'] ?? ''),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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'] ?? ''),
|
||||
|
||||
+2
-9
@@ -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)) {
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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);
|
||||
|
||||
+2
-4
@@ -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'] ?? '');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+2
-7
@@ -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'])) {
|
||||
|
||||
+2
-6
@@ -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'] ?? '');
|
||||
|
||||
+2
-4
@@ -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) {
|
||||
|
||||
+2
-4
@@ -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'] ?? '');
|
||||
|
||||
+2
-6
@@ -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'] ?? '');
|
||||
|
||||
+2
-7
@@ -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) {
|
||||
|
||||
+2
-10
@@ -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'])) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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);
|
||||
|
||||
+2
-4
@@ -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'] ?? '');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+2
-10
@@ -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) {
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+5
-11
@@ -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]);
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 增长引擎"卖点的最大机会。
|
||||
@@ -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 <version>`(可选,先支持正向即可);
|
||||
- 首次基线收录现有 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 兑现官网承诺,最后用舆情/竞品/人脉图谱做出差异化。**
|
||||
@@ -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`;
|
||||
@@ -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='数据库迁移版本表';
|
||||
+7
-1
@@ -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 || []);
|
||||
|
||||
|
||||
+1
-1
@@ -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 管理系统 版权所有 | 备案号:请替换为真实备案号';
|
||||
|
||||
@@ -333,6 +333,7 @@ $(function () {
|
||||
'<form id="cv-form" style="padding:16px 22px 4px;">' +
|
||||
'<div class="form-grid">' +
|
||||
' <div class="form-item"><label>碎片类型<span class="req">*</span></label>' + typeSel + '</div>' +
|
||||
' <div class="form-item"><label>来源渠道</label><select name="source_channel" id="cv-source"><option value="">-</option></select></div>' +
|
||||
' <div class="form-item" id="cv-f-company_name"><label>公司名称</label><input type="text" name="company_name" value="' + escHtml(d.company_name || '') + '"></div>' +
|
||||
' <div class="form-item" id="cv-f-person_name"><label>联系人</label><input type="text" name="person_name" value="' + escHtml(d.person_name || '') + '"></div>' +
|
||||
' <div class="form-item"><label>联系电话</label><input type="text" name="contact_phone" value="' + escHtml(d.contact_phone || '') + '"></div>' +
|
||||
@@ -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 = '<option value="">-</option>';
|
||||
(dd.source_channels || []).forEach(function (c) {
|
||||
html += '<option value="' + escHtml(c.name) + '">' + escHtml(c.name) + '</option>';
|
||||
});
|
||||
$('#cv-source').html(html).val(d.source_channel || '');
|
||||
}).catch(function () {});
|
||||
|
||||
function syncFields() {
|
||||
var t = $('#cv-type').val();
|
||||
|
||||
+14
-2
@@ -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';
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
/**
|
||||
* 数据库迁移执行器(批次1「迁移版本管理」基座)
|
||||
*
|
||||
* 用法:
|
||||
* php tools/migrate.php # 等同 up
|
||||
* php tools/migrate.php up # 顺序执行 sql/migration_*.sql 中未应用的迁移
|
||||
* php tools/migrate.php status # 列出已应用 / 待应用
|
||||
* php tools/migrate.php down <version> # 移除 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 <version>\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 <version>)\n");
|
||||
exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user