初始化

缺陷:主题配色需要优化整体的同风格
This commit is contained in:
2026-08-14 23:21:21 +08:00
parent 6e2769c528
commit bcf54c2727
152 changed files with 13521 additions and 141 deletions

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 角色 接口 授权关系
*
* 脚手架原本只有角色 菜单,接口注册表 nl_api_endpoint 仅用于匹配操作日志,
* 于是「菜单看不见但接口能直接调」。这张表把接口也纳入角色授权,
* 前端的按钮级权限码admin:list 这种)就是从这里推导出来的。
*/
return new class extends Migration {
public function up(): void
{
if (Schema::hasTable('role_endpoint_relation')) {
return;
}
Schema::create('role_endpoint_relation', function (Blueprint $table) {
$table->increments('id')->comment('主键');
$table->unsignedInteger('role_id')->default(0)->comment('角色ID');
$table->unsignedInteger('endpoint_id')->default(0)->comment('nl_api_endpoint 主键');
$table->integer('created_at')->default(0)->comment('创建时间');
$table->unique(['role_id', 'endpoint_id'], 'uk_role_endpoint');
$table->index('endpoint_id', 'idx_endpoint');
});
}
public function down(): void
{
Schema::dropIfExists('role_endpoint_relation');
}
};

View File

@@ -0,0 +1,68 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 企业表扩列cc_enterprise business 连接)
*
* 老表只有 name / logo做不了对公结算报价按企业默认倍率、开票要税号、
* 发货要联系人电话地址。这些列补齐后,微信用户绑定企业才有实际意义。
*/
return new class extends Migration {
public function up(): void
{
$schema = Schema::connection('business');
if (!$schema->hasTable('enterprise')) {
return;
}
$schema->table('enterprise', function (Blueprint $table) use ($schema) {
if (!$schema->hasColumn('enterprise', 'contact_name')) {
$table->string('contact_name', 50)->default('')->comment('联系人');
}
if (!$schema->hasColumn('enterprise', 'phone')) {
$table->string('phone', 30)->default('')->comment('联系电话');
}
if (!$schema->hasColumn('enterprise', 'address')) {
$table->string('address', 255)->default('')->comment('地址');
}
if (!$schema->hasColumn('enterprise', 'tax_no')) {
$table->string('tax_no', 50)->default('')->comment('税号');
}
if (!$schema->hasColumn('enterprise', 'settle_type')) {
$table->tinyInteger('settle_type')->default(0)->comment('结算方式 0款到发货 1月结 2账期');
}
if (!$schema->hasColumn('enterprise', 'price_number')) {
// 与 cc_wx_user.price_number 保持同类型,避免倍率计算时类型不一致
$table->string('price_number', 20)->default('1')->comment('默认价格倍率');
}
if (!$schema->hasColumn('enterprise', 'status')) {
$table->tinyInteger('status')->default(0)->comment('状态 0正常 1禁用');
}
if (!$schema->hasColumn('enterprise', 'remark')) {
$table->string('remark', 255)->default('')->comment('备注');
}
});
}
public function down(): void
{
$schema = Schema::connection('business');
if (!$schema->hasTable('enterprise')) {
return;
}
$schema->table('enterprise', function (Blueprint $table) {
$table->dropColumn([
'contact_name',
'phone',
'address',
'tax_no',
'settle_type',
'price_number',
'status',
'remark',
]);
});
}
};

View File

@@ -0,0 +1,137 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 文件表扩成素材库主表nl_file默认 mysql 连接)
*
* nl_file 只有 user_id + url本质是一张上传流水既没有对象键换了 CDN 域名
* 历史 url 全部对不上远端对象),也不知道哪张图还在被业务引用,于是既不能反向
* bucket 补录历史文件,也不敢删任何一个文件。这里补上对象键 / 体积 / 哈希 /
* 引用计数,再加一张 nl_file_folder 做逻辑分组,素材库才有底表可用。
*
* 注意source type 是老表本来就有的列,这里靠 hasColumn 跳过,
* 取值语义在 App\Models\FileModel 的常量里固化。
*/
return new class extends Migration {
/**
* 需要建索引的列path 用于引用匹配hash 用于同步判重,
* folder_id / ref_count 用于素材库左侧目录与「可回收」筛选
*/
private const INDEX_COLUMNS = ['path', 'hash', 'folder_id', 'ref_count'];
/**
* 回滚时要删掉的列source 不在其中,它是老表原有的列
*/
private const ADDED_COLUMNS = [
'oss_config_id', 'folder_id', 'name', 'path', 'ext',
'size', 'width', 'height', 'hash', 'ref_count', 'last_scan_at',
];
public function up(): void
{
$schema = Schema::connection('mysql');
if ($schema->hasTable('file')) {
$schema->table('file', function (Blueprint $table) use ($schema) {
if (!$schema->hasColumn('file', 'oss_config_id')) {
$table->integer('oss_config_id')->default(0)->comment('所属存储配置 nl_oss_config.id0 表示未知');
}
if (!$schema->hasColumn('file', 'folder_id')) {
$table->integer('folder_id')->default(0)->comment('素材文件夹 nl_file_folder.id0 根目录');
}
if (!$schema->hasColumn('file', 'name')) {
$table->string('name', 255)->default('')->comment('素材名称');
}
if (!$schema->hasColumn('file', 'path')) {
$table->string('path', 500)->default('')->comment('对象键,已去掉处理参数');
}
if (!$schema->hasColumn('file', 'ext')) {
$table->string('ext', 20)->default('')->comment('扩展名,小写不带点');
}
if (!$schema->hasColumn('file', 'size')) {
$table->bigInteger('size')->default(0)->comment('字节数');
}
if (!$schema->hasColumn('file', 'width')) {
$table->integer('width')->default(0)->comment('图片宽,非图片为 0');
}
if (!$schema->hasColumn('file', 'height')) {
$table->integer('height')->default(0)->comment('图片高,非图片为 0');
}
if (!$schema->hasColumn('file', 'hash')) {
$table->string('hash', 64)->default('')->comment('内容哈希 / ETag');
}
if (!$schema->hasColumn('file', 'ref_count')) {
$table->integer('ref_count')->default(0)->comment('引用次数,由引用扫描回填');
}
if (!$schema->hasColumn('file', 'last_scan_at')) {
$table->integer('last_scan_at')->default(0)->comment('最后一次引用扫描时间0 表示从未扫描过');
}
if (!$schema->hasColumn('file', 'source')) {
$table->tinyInteger('source')->default(0)->comment('来源 0上传 1OSS拉取');
}
});
// 索引单独一批 ALTER与新增列写在同一批时部分 MySQL 版本会报
// Key column 'path' doesn't exist in table
$schema->table('file', function (Blueprint $table) use ($schema) {
foreach (self::INDEX_COLUMNS as $column) {
if ($schema->hasColumn('file', $column) && !$schema->hasIndex('file', [$column])) {
$table->index($column);
}
}
});
// url 原来是 varchar(255):对象键放到 500 之后,域名 + 键很容易超过 255
// 严格模式下同步会直接写失败,所以跟着放宽。放在最后一批执行,
// 万一某些托管环境不允许改列,前面的扩列已经落库,素材库仍可用。
$schema->table('file', function (Blueprint $table) {
$table->string('url', 500)->default('')->comment('文件地址')->change();
});
}
if (!$schema->hasTable('file_folder')) {
$schema->create('file_folder', function (Blueprint $table) {
$table->increments('id')->comment('主键');
$table->integer('pid')->default(0)->comment('上级文件夹0 根目录');
$table->string('name', 100)->default('')->comment('文件夹名称');
$table->integer('sort')->default(0)->comment('排序,越小越靠前');
$table->tinyInteger('status')->default(0)->comment('状态 0正常 1禁用');
$table->integer('created_at')->default(0)->comment('创建时间');
$table->integer('updated_at')->default(0)->comment('更新时间');
$table->integer('deleted_at')->default(0)->comment('删除时间');
$table->index('pid');
});
}
}
public function down(): void
{
$schema = Schema::connection('mysql');
if ($schema->hasTable('file')) {
$schema->table('file', function (Blueprint $table) use ($schema) {
foreach (self::INDEX_COLUMNS as $column) {
if ($schema->hasIndex('file', [$column])) {
$table->dropIndex([$column]);
}
}
});
$columns = array_values(array_filter(
self::ADDED_COLUMNS,
static fn ($column) => Schema::connection('mysql')->hasColumn('file', $column)
));
if (!empty($columns)) {
$schema->table('file', function (Blueprint $table) use ($columns) {
$table->dropColumn($columns);
});
}
// url 不缩回 255现存数据里已经可能有更长的地址缩列会被静默截断
}
$schema->dropIfExists('file_folder');
}
};

View File

@@ -0,0 +1,233 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 清单与订单表
*
* 老库的 cc_list_item 只存 list_id + catalogue_id够做「收藏夹」但不够下单
* 缺规格、材质、数量与单价。下单金额必须是下单那一刻的快照,
* 只存 catalogue_id 的话后台改一次价格,所有历史订单的金额就跟着变了。
* 金额统一用整数分(*_amount / *_price 均为分),任何地方都不要用 float 存钱。
*/
return new class extends Migration
{
protected $connection = 'business';
public function up(): void
{
$schema = Schema::connection($this->connection);
// 清单编号QD_ + 日期 + 随机串,唯一索引 + 冲突重试保证不重号
if ($schema->hasTable('list')) {
$schema->table('list', function (Blueprint $table) use ($schema) {
if (!$schema->hasColumn('list', 'list_no')) {
$table->string('list_no', 32)->default('')->comment('清单编号 QD_YYYYMMDDxxxxxx');
}
if (!$schema->hasColumn('list', 'enterprise_id')) {
$table->integer('enterprise_id')->default(0)->comment('所属企业');
}
if (!$schema->hasColumn('list', 'remark')) {
$table->string('remark', 255)->default('')->comment('备注');
}
if (!$schema->hasColumn('list', 'status')) {
$table->tinyInteger('status')->default(0)->comment('0=正常 1=已下单');
}
});
// 存量清单先补号再加唯一索引,否则一堆空串直接撞唯一约束
$this->backfillListNo();
$this->addIndex('list', 'list_no', true);
$this->addIndex('list', 'user_id');
}
if ($schema->hasTable('list_item')) {
$schema->table('list_item', function (Blueprint $table) use ($schema) {
foreach ([
'price_sheet_id' => fn () => $table->integer('price_sheet_id')->default(0)->comment('规格行'),
'material_key' => fn () => $table->string('material_key', 50)->default('routine')->comment('材质键,老库只有 routine 在用'),
'quantity' => fn () => $table->integer('quantity')->default(1)->comment('数量'),
'unit_price' => fn () => $table->integer('unit_price')->default(0)->comment('单价快照(分)'),
'remark' => fn () => $table->string('remark', 255)->default('')->comment('备注'),
] as $column => $add) {
if (!$schema->hasColumn('list_item', $column)) {
$add();
}
}
});
$this->addIndex('list_item', 'list_id');
$this->addIndex('list_item', 'catalogue_id');
}
if (!$schema->hasTable('order')) {
$schema->create('order', function (Blueprint $table) {
$table->increments('id');
$table->string('order_no', 32)->default('')->comment('订单号 DD_YYYYMMDDxxxxxx');
$table->integer('list_id')->default(0)->comment('来源清单');
$table->integer('user_id')->default(0)->comment('下单用户 cc_wx_user.id');
$table->integer('enterprise_id')->default(0)->comment('所属企业');
$table->integer('total_amount')->default(0)->comment('订单总额(分)');
$table->integer('paid_amount')->default(0)->comment('已收款(分)');
$table->tinyInteger('pay_type')->default(0)->comment('0=未选 1=转账凭证 2=微信支付');
$table->tinyInteger('pay_status')->default(0)->comment('0=待付款 1=待审核 2=已付款 3=已驳回');
$table->integer('paid_at')->default(0);
$table->tinyInteger('delivery_type')->default(0)->comment('0=未选 1=物流 2=自提 3=公司配送');
// 收件信息必须快照:用户改了资料不能影响历史订单
$table->string('receiver_name', 50)->default('');
$table->string('receiver_phone', 20)->default('');
$table->string('receiver_address', 255)->default('');
$table->tinyInteger('status')->default(0)->comment('0=待付款 1=待发货 2=已发货 3=已完成 4=已取消');
$table->string('remark', 255)->default('');
$table->integer('created_at')->default(0);
$table->integer('updated_at')->default(0);
$table->integer('deleted_at')->default(0);
$table->unique('order_no', 'uk_order_no');
$table->index('user_id', 'idx_order_user');
$table->index('list_id', 'idx_order_list');
$table->index('status', 'idx_order_status');
});
}
if (!$schema->hasTable('order_item')) {
$schema->create('order_item', function (Blueprint $table) {
$table->increments('id');
$table->integer('order_id')->default(0);
$table->integer('catalogue_id')->default(0)->comment('仅作溯源,展示一律用下面的快照');
$table->integer('price_sheet_id')->default(0);
$table->string('title', 255)->default('')->comment('商品名快照');
$table->string('cover', 500)->default('')->comment('封面快照');
$table->string('alias', 100)->default('')->comment('编号快照');
$table->string('specification', 255)->default('')->comment('规格快照');
$table->string('dimension', 255)->default('')->comment('尺寸快照');
$table->string('material_key', 50)->default('routine');
$table->integer('quantity')->default(1);
$table->integer('unit_price')->default(0)->comment('单价(分)');
$table->integer('total_price')->default(0)->comment('小计(分)');
$table->string('remark', 255)->default('');
$table->integer('created_at')->default(0);
$table->integer('updated_at')->default(0);
$table->integer('deleted_at')->default(0);
$table->index('order_id', 'idx_order_item_order');
});
}
if (!$schema->hasTable('order_payment')) {
$schema->create('order_payment', function (Blueprint $table) {
$table->increments('id');
$table->integer('order_id')->default(0);
$table->tinyInteger('pay_type')->default(1)->comment('1=转账凭证 2=微信支付');
$table->integer('amount')->default(0)->comment('金额(分)');
$table->string('voucher', 500)->default('')->comment('转账凭证图,多张逗号分隔');
$table->string('transaction_id', 64)->default('')->comment('微信支付单号');
$table->string('out_trade_no', 64)->default('')->comment('商户订单号,回调幂等靠它');
$table->tinyInteger('status')->default(0)->comment('0=待审核 1=已确认 2=已驳回');
$table->integer('auditor_id')->default(0)->comment('审核人 nl_admin.id');
$table->integer('audited_at')->default(0);
$table->string('audit_remark', 255)->default('');
$table->integer('created_at')->default(0);
$table->integer('updated_at')->default(0);
$table->integer('deleted_at')->default(0);
$table->index('order_id', 'idx_order_payment_order');
// 微信会重复推送回调,靠这个唯一索引兜住幂等
$table->unique('out_trade_no', 'uk_out_trade_no');
});
}
if (!$schema->hasTable('order_delivery')) {
$schema->create('order_delivery', function (Blueprint $table) {
$table->increments('id');
$table->integer('order_id')->default(0);
$table->tinyInteger('delivery_type')->default(1)->comment('1=物流 2=自提 3=公司配送');
$table->string('company', 100)->default('')->comment('物流公司');
$table->string('tracking_no', 64)->default('')->comment('运单号');
$table->string('pickup_point', 255)->default('')->comment('自提点');
$table->string('driver_info', 255)->default('')->comment('配送司机与车牌');
$table->integer('shipped_at')->default(0);
$table->string('remark', 255)->default('');
$table->integer('operator_id')->default(0)->comment('操作人 nl_admin.id');
$table->integer('created_at')->default(0);
$table->integer('updated_at')->default(0);
$table->integer('deleted_at')->default(0);
$table->index('order_id', 'idx_order_delivery_order');
});
}
// 小程序装修模板tokens 是设计令牌(配色/字号/圆角/动效layout 是页面骨架
if (!$schema->hasTable('wx_template')) {
$schema->create('wx_template', function (Blueprint $table) {
$table->increments('id');
$table->string('name', 50)->default('');
$table->string('code', 50)->default('')->comment('模板标识,小程序按它取样式');
$table->string('preview', 500)->default('')->comment('预览图');
$table->string('style_tag', 50)->default('')->comment('风格标签,如轻奢/极简');
$table->text('tokens')->nullable()->comment('设计令牌 JSON');
$table->text('layout')->nullable()->comment('页面骨架 JSON');
$table->tinyInteger('is_default')->default(0);
$table->tinyInteger('status')->default(0)->comment('0=启用 1=停用');
$table->string('app_code', 20)->default('')->comment('绑定品牌,空=通用');
$table->integer('version')->default(1);
$table->integer('sort')->default(0);
$table->integer('created_at')->default(0);
$table->integer('updated_at')->default(0);
$table->integer('deleted_at')->default(0);
$table->unique(['code', 'app_code'], 'uk_wx_template_code');
});
}
}
public function down(): void
{
$schema = Schema::connection($this->connection);
foreach (['wx_template', 'order_delivery', 'order_payment', 'order_item', 'order'] as $table) {
$schema->dropIfExists($table);
}
if ($schema->hasTable('list')) {
$schema->table('list', function (Blueprint $table) {
$table->dropColumn(['list_no', 'enterprise_id', 'status']);
});
}
if ($schema->hasTable('list_item')) {
$schema->table('list_item', function (Blueprint $table) {
$table->dropColumn(['price_sheet_id', 'material_key', 'quantity', 'unit_price']);
});
}
}
/**
* 给存量清单补编号:用 id 派生而不是随机串,重跑迁移结果一致,也不会自己撞自己
*/
private function backfillListNo(): void
{
DB::connection($this->connection)
->table('list')
->where('list_no', '')
->orderBy('id')
->chunkById(500, function ($rows) {
foreach ($rows as $row) {
$day = ((int) ($row->created_at ?? 0)) > 0 ? date('Ymd', (int) $row->created_at) : date('Ymd');
DB::connection($this->connection)
->table('list')
->where('id', $row->id)
->update(['list_no' => 'QD_' . $day . 'L' . str_pad((string) $row->id, 6, '0', STR_PAD_LEFT)]);
}
});
}
/**
* 加索引;已存在时静默跳过,避免重复执行迁移直接报错
*/
private function addIndex(string $table, string $column, bool $unique = false): void
{
$schema = Schema::connection($this->connection);
$indexName = ($unique ? 'uk_' : 'idx_') . $table . '_' . $column;
try {
$schema->table($table, function (Blueprint $blueprint) use ($column, $unique, $indexName) {
$unique ? $blueprint->unique($column, $indexName) : $blueprint->index($column, $indexName);
});
} catch (\Throwable) {
// 索引已存在
}
}
};

View File

@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 小程序应用配置nl_wx_app
*
* 两个品牌两个 AppID老项目把 AppID AppSecret 硬编码在 WeChatService 里,
* 换品牌要改代码、密钥还进了 git 历史。这里落库并加密存储,
* 小程序请求头带 app_code 决定用哪套凭证。
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('wx_app')) {
return;
}
Schema::create('wx_app', function (Blueprint $table) {
$table->increments('id');
$table->string('code', 20)->default('')->comment('品牌标识 brm/wl');
$table->string('name', 50)->default('');
$table->string('app_id', 50)->default('');
$table->string('app_secret', 255)->default('')->comment('密文存储');
$table->string('mch_id', 50)->default('')->comment('微信支付商户号');
$table->string('mch_key', 255)->default('')->comment('APIv3 密钥,密文存储');
$table->string('mch_serial_no', 100)->default('')->comment('商户证书序列号');
$table->text('mch_private_key')->nullable()->comment('商户私钥,密文存储');
// 回调验签要用微信支付平台证书公钥;没有它就没法验签,回调只能一律判失败
$table->text('platform_public_key')->nullable()->comment('微信支付平台证书公钥');
$table->string('notify_url', 255)->default('')->comment('支付回调地址');
$table->string('template_code', 50)->default('')->comment('默认装修模板 code');
$table->tinyInteger('status')->default(0)->comment('0=启用 1=停用');
$table->string('remark', 255)->default('');
$table->integer('created_at')->default(0);
$table->integer('updated_at')->default(0);
$table->integer('deleted_at')->default(0);
$table->unique('code', 'uk_wx_app_code');
});
}
public function down(): void
{
Schema::dropIfExists('wx_app');
}
};

View File

@@ -0,0 +1,54 @@
<?php
namespace Database\Seeders;
use App\Models\WxAppModel;
use Illuminate\Database\Seeder;
/**
* 小程序应用配置引导nl_wx_app
*
* 用途:单品牌部署首次上线时,把 .env 里声明的 AppID 引导落库一条记录,
* 免得运维还要进后台手工新建。AppSecret 不能从 .env 读,留空由后台填。
*
* 执行php artisan db:seed --class=WxAppSeeder
* 不挂 DatabaseSeeder::run(),避免 CI / 自动测试里污染库表。
*/
class WxAppSeeder extends Seeder
{
public function run(): void
{
$code = trim((string) env('WX_DEFAULT_APP_CODE', ''));
$appId = trim((string) env('WX_APP_ID', ''));
$name = trim((string) env('WX_APP_NAME', ''));
// .env 没配就不跑,避免插空记录
if ($code === '' || $appId === '') {
$this->command->warn('WX_DEFAULT_APP_CODE / WX_APP_ID 未配置,跳过 WxAppSeeder。');
return;
}
// 用 firstOrCreate 防重复:已存在相同 code 的记录就只更新 AppID/名称,不动密钥
// 不直接写 app_secret —— 密钥必须经后台加密入库
$row = WxAppModel::where('code', $code)->where('deleted_at', 0)->first();
if (!empty($row)) {
$row->update([
'app_id' => $appId,
'name' => $name !== '' ? $name : $row->name,
'updated_at' => time(),
]);
$this->command->info("已更新品牌 [{$code}] 的 AppID 为 {$appId}AppSecret 未改动。");
return;
}
WxAppModel::create([
'code' => $code,
'name' => $name,
'app_id' => $appId,
'status' => 0,
'created_at' => time(),
'updated_at' => time(),
]);
$this->command->info("已新增品牌 [{$code}] 应用记录,请进后台「小程序应用配置」填写 AppSecret。");
}
}

112
database/sql/rbac_audit.sql Normal file
View File

@@ -0,0 +1,112 @@
-- ============================================================
-- 账号与角色迁移前数据体检(只读,可直接粘进 Navicat / phpMyAdmin 执行)
--
-- 与 `php artisan lgp:rbac-audit` 等价,供未安装依赖时使用。
-- 库cc_new_stash老业务表 cc_ 与新系统表 nl_ 同库)
-- ============================================================
-- ------------------------------------------------------------
-- [1] 迁移规模
-- ------------------------------------------------------------
SELECT 'cc_user 待迁账号' AS item, COUNT(*) AS cnt FROM cc_user WHERE deleted_at = 0
UNION ALL SELECT 'cc_role 待迁角色', COUNT(*) FROM cc_role WHERE deleted_at = 0
UNION ALL SELECT 'cc_department 部门(复用不迁)', COUNT(*) FROM cc_department WHERE deleted_at = 0
UNION ALL SELECT 'cc_user_role_relation 用户角色关系', COUNT(*) FROM cc_user_role_relation
UNION ALL SELECT 'cc_role_menu_relation 角色菜单关系', COUNT(*) FROM cc_role_menu_relation
UNION ALL SELECT 'nl_admin 现有账号', COUNT(*) FROM nl_admin WHERE deleted_at = 0
UNION ALL SELECT 'nl_role 现有角色', COUNT(*) FROM nl_role WHERE deleted_at = 0;
-- ------------------------------------------------------------
-- [2] 绑定了多个角色的用户
-- 新库 nl_admin.role_id 是单字段,多绑的必须人工决定取哪个
-- ------------------------------------------------------------
SELECT ur.user_id, u.account, u.nick_name,
COUNT(*) AS role_count,
GROUP_CONCAT(ur.role_id ORDER BY ur.role_id) AS role_ids
FROM cc_user_role_relation ur
JOIN cc_user u ON u.id = ur.user_id
WHERE u.deleted_at = 0
GROUP BY ur.user_id, u.account, u.nick_name
HAVING role_count > 1;
-- [2b] 完全没有角色关系的账号(迁过去 role_id=0看不到任何菜单
SELECT u.id, u.account, u.nick_name
FROM cc_user u
WHERE u.deleted_at = 0
AND NOT EXISTS (SELECT 1 FROM cc_user_role_relation ur WHERE ur.user_id = u.id);
-- ------------------------------------------------------------
-- [3] 重复手机号
-- 新库登录是 WHERE phone = ? LIMIT 1重复会登录到错误的人
-- ------------------------------------------------------------
SELECT phone, COUNT(*) AS c,
GROUP_CONCAT(id ORDER BY id) AS user_ids,
GROUP_CONCAT(account ORDER BY id) AS accounts
FROM cc_user
WHERE deleted_at = 0
GROUP BY phone
HAVING c > 1;
-- ------------------------------------------------------------
-- [4] 手机号长度异常
-- 新库 nl_admin.phone 是 char(11),超长会被截断,空值无法登录
-- ------------------------------------------------------------
SELECT id, account, nick_name, phone, CHAR_LENGTH(phone) AS len
FROM cc_user
WHERE deleted_at = 0
AND (phone = '' OR CHAR_LENGTH(phone) <> 11);
-- ------------------------------------------------------------
-- [5] 老角色清单(含账号数与菜单数)
-- 新库 nl_role.value 必填,需为每个角色指定英文标识
-- ------------------------------------------------------------
SELECT r.id, r.name, r.`desc`, r.status AS `status_0正常_1禁用`, r.color,
(SELECT COUNT(*) FROM cc_user_role_relation ur
JOIN cc_user u ON u.id = ur.user_id
WHERE ur.role_id = r.id AND u.deleted_at = 0) AS user_count,
(SELECT COUNT(*) FROM cc_role_menu_relation rm
WHERE rm.role_id = r.id) AS menu_count
FROM cc_role r
WHERE r.deleted_at = 0
ORDER BY r.id;
-- ------------------------------------------------------------
-- [6] 角色 ID 撞车
-- nl_role.id=1 是超级管理员且代码里硬判断 role_id===1 全量放行
-- 老库 id=1 未必是超管,按原 ID 迁会把普通角色提权成超管
-- ------------------------------------------------------------
SELECT '老 cc_role' AS src, id, name FROM cc_role WHERE id = 1
UNION ALL
SELECT '新 nl_role', id, name FROM nl_role WHERE id = 1;
SELECT MAX(id) AS old_role_max_id, MAX(id) + 100 AS suggested_new_max
FROM cc_role WHERE deleted_at = 0;
-- ------------------------------------------------------------
-- [7] 密码格式
-- 惰性升级依赖无盐 sha140 位 hex非此格式只能走重置
-- ------------------------------------------------------------
SELECT
SUM(CHAR_LENGTH(password) = 40 AND password REGEXP '^[0-9a-f]{40}$') AS sha1_ok,
SUM(password = '') AS empty_pwd,
SUM(password <> '' AND NOT (CHAR_LENGTH(password) = 40 AND password REGEXP '^[0-9a-f]{40}$')) AS other_format
FROM cc_user
WHERE deleted_at = 0;
-- ------------------------------------------------------------
-- [8] 部门悬空引用cc_user.department 指向已不存在的部门)
-- ------------------------------------------------------------
SELECT u.id, u.account, u.department
FROM cc_user u
WHERE u.deleted_at = 0
AND u.department > 0
AND NOT EXISTS (SELECT 1 FROM cc_department d WHERE d.id = u.department AND d.deleted_at = 0);
-- ------------------------------------------------------------
-- [9] account 与 phone 不一致的账号
-- account 从不参与登录(老 UserService::login 查的是 phone
-- 迁移时把原 account 写进 nl_admin.desc 留痕即可,不新增列
-- ------------------------------------------------------------
SELECT COUNT(*) AS account_phone_diff
FROM cc_user
WHERE deleted_at = 0 AND account <> phone;

View File

@@ -0,0 +1,124 @@
-- ============================================================
-- RBAC 表结构补丁(幂等,可重复执行)
-- 文件database/sql/rbac_migrate_ddl.sql
--
-- 用途:给 nl_admin / nl_role 补齐迁移与日常接口所需列
-- - nl_admin.department_id → 解除 admin/my-info 报 Unknown column
-- - nl_admin.legacy_password* → 老无盐 sha1 惰性升级
-- - nl_role.status / color → 角色禁用与标签色
--
-- 用法(任选其一):
-- 1. Navicat选中库 cc_new_stash → 新建查询 → 整份粘贴 → 运行
-- (本文件不用 DELIMITER / 存储过程Navicat 可直接跑)
-- 2. mysql 客户端:
-- mysql -h... -u... -p cc_new_stash < database/sql/rbac_migrate_ddl.sql
--
-- 说明:
-- - 用 information_schema + PREPARE 实现「列/索引已存在则跳过」
-- - 不依赖 Laravel DB_PREFIX
-- - uk_phone 唯一索引在文末【可选】段,有重复手机号时会失败
-- ============================================================
-- ------------------------------------------------------------
-- nl_admin.department_id
-- ------------------------------------------------------------
SET @db := DATABASE();
SET @exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_admin' AND COLUMN_NAME = 'department_id'
);
SET @sql := IF(@exists = 0,
'ALTER TABLE `nl_admin` ADD COLUMN `department_id` int NOT NULL DEFAULT 0 COMMENT ''所属部门ID对应 cc_department.id'' AFTER `role_id`',
'SELECT ''skip nl_admin.department_id'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ------------------------------------------------------------
-- nl_admin.legacy_password
-- ------------------------------------------------------------
SET @exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_admin' AND COLUMN_NAME = 'legacy_password'
);
SET @sql := IF(@exists = 0,
'ALTER TABLE `nl_admin` ADD COLUMN `legacy_password` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '''' COMMENT ''老系统无盐sha1密码登录成功后清空'' AFTER `password`',
'SELECT ''skip nl_admin.legacy_password'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ------------------------------------------------------------
-- nl_admin.legacy_password_expire_at
-- ------------------------------------------------------------
SET @exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_admin' AND COLUMN_NAME = 'legacy_password_expire_at'
);
SET @sql := IF(@exists = 0,
'ALTER TABLE `nl_admin` ADD COLUMN `legacy_password_expire_at` int NOT NULL DEFAULT 0 COMMENT ''遗留密码失效时间,过期强制走重置'' AFTER `legacy_password`',
'SELECT ''skip nl_admin.legacy_password_expire_at'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ------------------------------------------------------------
-- nl_admin 索引 idx_role / idx_department
-- ------------------------------------------------------------
SET @exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_admin' AND INDEX_NAME = 'idx_role'
);
SET @sql := IF(@exists = 0,
'ALTER TABLE `nl_admin` ADD INDEX `idx_role`(`role_id` ASC, `deleted_at` ASC) USING BTREE',
'SELECT ''skip idx_role'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_admin' AND INDEX_NAME = 'idx_department'
);
SET @sql := IF(@exists = 0,
'ALTER TABLE `nl_admin` ADD INDEX `idx_department`(`department_id` ASC, `deleted_at` ASC) USING BTREE',
'SELECT ''skip idx_department'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ------------------------------------------------------------
-- nl_role.status / color (你当前 audit 缺的就是这两列)
-- ------------------------------------------------------------
SET @exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_role' AND COLUMN_NAME = 'status'
);
SET @sql := IF(@exists = 0,
'ALTER TABLE `nl_role` ADD COLUMN `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT ''状态 0正常 1禁用'' AFTER `desc`',
'SELECT ''skip nl_role.status'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_role' AND COLUMN_NAME = 'color'
);
SET @sql := IF(@exists = 0,
'ALTER TABLE `nl_role` ADD COLUMN `color` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '''' COMMENT ''角色标签颜色'' AFTER `status`',
'SELECT ''skip nl_role.color'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ------------------------------------------------------------
-- 修正种子:超管 status 必须是 00正常 1禁用
-- ------------------------------------------------------------
UPDATE `nl_admin` SET `status` = 0 WHERE `id` = 1 AND `status` = 1;
-- ============================================================
-- 【可选】uk_phone —— 先跑 lgp:rbac-audit 确认手机号无重复后再执行
-- ============================================================
-- SET @exists := (
-- SELECT COUNT(*) FROM information_schema.STATISTICS
-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'nl_admin' AND INDEX_NAME = 'uk_phone'
-- );
-- SET @sql := IF(@exists = 0,
-- 'ALTER TABLE `nl_admin` ADD UNIQUE INDEX `uk_phone`(`phone` ASC) USING BTREE',
-- 'SELECT ''skip uk_phone'' AS msg'
-- );
-- PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;