Files
YwxAppThink/scripts/purge_builtin_shop.php
T
yangwx 18101cf11a feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

167 lines
6.2 KiB
PHP

<?php
/**
* 一次性脚本:清理内置「电子商务」(Shop)模块在已装好的库里的残留数据。
*
* 背景:内置商城已整体下线(控制器 / 验证器 / 视图 / 静态 JS / CommonShop 模型全部删除,
* install.sql 里的菜单种子与 common_shop 表定义也一并移除)。但已经装好的库不会被这些改动影响:
* MenuRegistry::ensure() 只会「缺了就补」,从来不删,于是「交易营销 → 电子商务」会变成一个
* 点了就报错的僵尸菜单;wxapp_common_shop 表也会一直挂着。
*
* 本脚本处理三样东西(幂等,找不到就什么都不做):
* 1. backend_power 里 shop / shop:* 的权限节点(含挂在这些节点下的子按钮)
* 2. backend_role_power 里指向它们的角色授权记录(不删会留下孤儿行)
* 3. wxapp_common_shop 数据表
*
* 用法:
* php scripts/purge_builtin_shop.php # 预演:只统计、不动库(默认)
* php scripts/purge_builtin_shop.php --force # 真正执行
*
* 依赖:只需 PDO + mysql 扩展,不加载框架(里的 .env / config/database.php 自己读)。
*/
declare(strict_types=1);
$root = dirname(__DIR__) . DIRECTORY_SEPARATOR;
$force = in_array('--force', $argv ?? [], true);
echo $force
? "[模式] 执行:以下动作会真实落库\n"
: "[模式] 预演(dry-run):只统计不改动,加 --force 才执行\n";
/* ------------------------- 1. 读配置:极简 env() + include database.php ------------------------- */
$GLOBALS['__env'] = [];
foreach ([$root . '.env', $root . '.env.local'] as $envFile) {
if (! is_file($envFile)) {
continue;
}
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') {
continue;
}
$pos = strpos($line, '=');
if ($pos === false) {
continue;
}
$GLOBALS['__env'][trim(substr($line, 0, $pos))] = trim(trim(substr($line, $pos + 1)), "\"'");
}
}
if (! function_exists('env')) {
function env(string $key = '', $default = null)
{
return $GLOBALS['__env'][$key] ?? $default;
}
}
$dbConfFile = $root . 'config' . DIRECTORY_SEPARATOR . 'database.php';
if (! is_file($dbConfFile)) {
fwrite(STDERR, "找不到配置文件: {$dbConfFile}\n");
exit(1);
}
$dbConf = include $dbConfFile;
$conn = $dbConf['connections'][$dbConf['default'] ?? 'mysql'] ?? [];
/* ------------------------- 2. 连库 ------------------------- */
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$conn['hostname'] ?? '127.0.0.1',
(int) ($conn['hostport'] ?? 3306),
$conn['database'] ?? '',
$conn['charset'] ?: 'utf8mb4'
);
try {
$pdo = new PDO($dsn, $conn['username'] ?? 'root', $conn['password'] ?? '', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (Throwable $e) {
fwrite(STDERR, '数据库连接失败: ' . $e->getMessage() . "\n");
exit(1);
}
$prefix = (string) ($conn['prefix'] ?? '');
$powerTable = $prefix . 'backend_power';
$rolePowerTable = $prefix . 'backend_role_power';
$shopTable = $prefix . 'common_shop';
echo "[连接] {$conn['hostname']}:" . ($conn['hostport'] ?? 3306)
. " / {$conn['database']} (表前缀 {$prefix})\n";
/* ------------------------- 3. 菜单 / 权限节点 + 角色授权 ------------------------- */
$tableExists = static function (string $table) use ($pdo): bool {
return (bool) $pdo->query("SHOW TABLES LIKE '{$table}'")->fetchColumn();
};
if (! $tableExists($powerTable)) {
echo "[权限] {$powerTable} 不存在,跳过\n";
echo "[数据表] {$shopTable} 不存在,跳过\n";
exit(0);
}
$stmt = $pdo->prepare(
"SELECT `id`, `name`, `title` FROM `{$powerTable}`
WHERE `name` = 'shop' OR `name` LIKE 'shop:%'
ORDER BY `id`"
);
$stmt->execute();
$powers = $stmt->fetchAll();
if (! $powers) {
echo "[权限] 没有 shop 相关权限节点,跳过\n";
} else {
echo '[权限] 命中 ' . count($powers) . " 个节点:\n";
foreach ($powers as $row) {
echo sprintf(" #%d %s (%s)\n", (int) $row['id'], $row['name'], $row['title']);
}
$ids = array_map('intval', array_column($powers, 'id'));
$in = implode(',', $ids);
$hasRolePower = $tableExists($rolePowerTable);
$grantRows = $hasRolePower
? (int) $pdo->query("SELECT COUNT(*) FROM `{$rolePowerTable}` WHERE `power_id` IN ({$in})")->fetchColumn()
: 0;
$powerRows = (int) $pdo->query("SELECT COUNT(*) FROM `{$powerTable}` WHERE `id` IN ({$in}) OR `pid` IN ({$in})")->fetchColumn();
echo "[授权] {$rolePowerTable} 待删 {$grantRows} 条\n";
echo "[权限] {$powerTable} 待删 {$powerRows} 条(含挂在它们下面的子按钮)\n";
if ($force) {
$pdo->beginTransaction();
try {
if ($hasRolePower) {
$pdo->exec("DELETE FROM `{$rolePowerTable}` WHERE `power_id` IN ({$in})");
}
$affected = $pdo->exec("DELETE FROM `{$powerTable}` WHERE `id` IN ({$in}) OR `pid` IN ({$in})");
$pdo->commit();
echo "[执行] 已删授权 {$grantRows} 条、权限节点 {$affected} 条\n";
} catch (Throwable $e) {
$pdo->rollBack();
fwrite(STDERR, '[失败] 权限清理回滚: ' . $e->getMessage() . "\n");
exit(1);
}
}
}
/* ------------------------- 4. 数据表 ------------------------- */
if ($pdo->query("SHOW TABLES LIKE '{$shopTable}'")->fetchColumn()) {
$rows = (int) $pdo->query("SELECT COUNT(*) FROM `{$shopTable}`")->fetchColumn();
echo "[数据表] {$shopTable} 存在,共 {$rows} 行\n";
if ($force) {
$pdo->exec("DROP TABLE `{$shopTable}`");
echo "[执行] 已 DROP TABLE `{$shopTable}`\n";
}
} else {
echo "[数据表] {$shopTable} 不存在,跳过\n";
}
echo $force
? "[完成] 内置商城残留已清理。后台菜单即时生效(MenuRegistry 每次请求读库,无缓存)\n"
: "[提示] 以上为预演结果,未做任何改动。确认后执行: php scripts/purge_builtin_shop.php --force\n";