AI 生成后台删除按钮后,MySQL 软删除和唯一索引怎么验

AI 把后台删除按钮和接口生成出来以后,最危险的地方不是"会不会删",而是删完以后系统看起来正常,数据却开始悄悄乱掉:用户重新创建时报唯一索引冲突,普通账号绕过前端直接调用删除接口,恢复数据时撞上新记录,列表里看不见的旧数据还在影响统计。

所以删除功能不能只验页面。后台里只要出现软删除,就要同时验三件事:MySQL 唯一索引怎么处理,Go 接口权限有没有拦住,审计日志能不能还原是谁删的。少一项,后面排查都会很痛。

先把问题缩小:软删除不是多一个 deleted_at

很多 AI 生成的 CRUD 会给表加一个 deleted_at 字段,然后列表查询默认带上 deleted_at IS NULL。这一步没错,但它只解决"列表不显示已删除数据"。它没解决下面几个问题:

  • 唯一字段还能不能重新使用;
  • 删除接口是不是只允许有权限的人调用;
  • 批量删除是否会跨租户、跨组织;
  • 恢复数据时是否撞上当前有效数据;
  • 日志里是否能查到删除原因、操作者和请求来源。

如果你的后台只有"删除成功"的 toast,没有这些验收,基本等于把问题推迟到线上。

第一步:用一张表复现唯一索引冲突

假设后台有一张客户表,手机号在有效客户里必须唯一。最容易写错的表结构是这样:

sql 复制代码
CREATE TABLE crm_customer (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  tenant_id BIGINT NOT NULL,
  mobile VARCHAR(20) NOT NULL,
  name VARCHAR(64) NOT NULL,
  deleted_at DATETIME NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY uk_tenant_mobile (tenant_id, mobile)
);

这个唯一索引看着合理,实际会在软删除后卡住。你把某个客户删掉以后,再用同一个手机号新建客户,MySQL 仍然认为 tenant_id + mobile 已存在,因为那条旧记录只是打了删除标记。

sql 复制代码
UPDATE crm_customer
SET deleted_at = NOW()
WHERE tenant_id = 1001 AND mobile = '13800001111';

INSERT INTO crm_customer (tenant_id, mobile, name)
VALUES (1001, '13800001111', '新客户');
-- Duplicate entry '1001-13800001111' for key 'uk_tenant_mobile'

这类问题在页面上不一定立刻暴露。前端只会提示"保存失败"或"手机号已存在",但业务同学会觉得奇怪:明明刚刚已经删除,为什么还不能重新录入?

第二步:让唯一索引只约束有效数据

MySQL 里常见做法是加一个"是否有效"的生成列,让唯一索引只卡住未删除记录。示例写法如下:

sql 复制代码
ALTER TABLE crm_customer
  ADD COLUMN active_flag TINYINT
  GENERATED ALWAYS AS (IF(deleted_at IS NULL, 1, NULL)) STORED;

DROP INDEX uk_tenant_mobile ON crm_customer;

CREATE UNIQUE INDEX uk_tenant_mobile_active
ON crm_customer (tenant_id, mobile, active_flag);

这里利用了 MySQL 唯一索引对 NULL 的处理方式。有效记录的 active_flag 是 1,同一租户下手机号不能重复;软删除记录的 active_flagNULL,不会继续挡住新记录。

上线前别只看 DDL 能不能执行,要跑一遍验证 SQL:

sql 复制代码
-- 1. 当前有效数据不能重复
SELECT tenant_id, mobile, COUNT(*) AS cnt
FROM crm_customer
WHERE deleted_at IS NULL
GROUP BY tenant_id, mobile
HAVING cnt > 1;

-- 2. 已软删除数据是否还占住唯一字段
SELECT tenant_id, mobile, COUNT(*) AS deleted_cnt
FROM crm_customer
WHERE deleted_at IS NOT NULL
GROUP BY tenant_id, mobile
HAVING deleted_cnt > 1;

-- 3. 重新创建同手机号是否只生成一条有效记录
SELECT id, tenant_id, mobile, deleted_at, active_flag
FROM crm_customer
WHERE tenant_id = 1001 AND mobile = '13800001111'
ORDER BY id DESC;

第一条必须返回空。第三条允许有历史删除记录,但只能有一条 deleted_at IS NULL 的有效记录。

第三步:删除接口不要只靠前端按钮控制

AI 很容易生成"列表按钮隐藏"的权限控制。这个控制只能改善界面,不能保护接口。真正要验的是直接请求删除接口时,后端是否返回 401 或 403。

Go 侧建议把删除、恢复、批量删除拆成不同权限码,不要全部挂在一个"编辑"权限下面。请求参数也要带上操作者、租户上下文和目标 ID,不能从前端传来的 tenant_id 直接相信。

go 复制代码
type DeleteCustomerReq struct {
    Ids []uint64 `json:"ids" v:"required#请选择要删除的客户"`
}

type RestoreCustomerReq struct {
    Id uint64 `json:"id" v:"required#请选择要恢复的客户"`
}

func DeleteCustomer(ctx context.Context, req DeleteCustomerReq) error {
    user := MustAdminUser(ctx)
    tenantID := user.TenantID

    if !HasPermission(ctx, "crm:customer:delete") {
        return ErrNoPermission
    }

    _, err := g.DB().Model("crm_customer").Ctx(ctx).
        Where("tenant_id", tenantID).
        WhereIn("id", req.Ids).
        WhereNull("deleted_at").
        Data(g.Map{
            "deleted_at": gtime.Now(),
            "deleted_by": user.Id,
        }).
        Update()
    return err
}

注意这里的重点不是代码长什么样,而是三个边界必须同时出现:权限码、租户条件、未删除条件。少了租户条件,批量删除最容易跨数据域;少了未删除条件,重复删除会污染日志;少了权限码,前端隐藏按钮没有任何意义。

第四步:用 curl 把 401、403、200 都打出来

删除功能上线前,至少跑下面几类请求。不要只用管理员账号测成功路径。

bash 复制代码
# 未登录:应该是 401
curl -i -X POST 'https://demo.example.com/admin/crm/customer/delete' \
  -H 'Content-Type: application/json' \
  -d '{"ids":[101]}'

# 已登录但无删除权限:应该是 403
curl -i -X POST 'https://demo.example.com/admin/crm/customer/delete' \
  -H 'Authorization: Bearer USER_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"ids":[101]}'

# 有删除权限但夹带别的租户 ID:应该只影响当前租户,不能跨租户删除
curl -i -X POST 'https://demo.example.com/admin/crm/customer/delete' \
  -H 'Authorization: Bearer ADMIN_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"ids":[101,202]}'

# 有权限恢复:恢复前先查唯一字段是否冲突
curl -i -X POST 'https://demo.example.com/admin/crm/customer/restore' \
  -H 'Authorization: Bearer ADMIN_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"id":101}'

如果第四个请求直接恢复成功,也不要马上高兴。恢复前应该先查当前有效数据里是否已经有同租户同手机号。否则恢复会把唯一约束问题重新带回来。

第五步:恢复比删除更容易被漏测

删除只是把有效数据变成无效数据;恢复是把历史数据重新放回有效集合。恢复前至少做一次冲突检查:

sql 复制代码
SELECT c1.id AS deleted_id, c2.id AS active_id, c1.tenant_id, c1.mobile
FROM crm_customer c1
JOIN crm_customer c2
  ON c1.tenant_id = c2.tenant_id
 AND c1.mobile = c2.mobile
 AND c2.deleted_at IS NULL
WHERE c1.id = 101
  AND c1.deleted_at IS NOT NULL;

这条 SQL 有结果时,恢复接口就不应该直接执行。更好的返回是明确告诉管理员:"手机号已被当前有效客户占用,请先合并或修改手机号"。不要把数据库唯一索引错误原样甩给前端。

第六步:审计日志要能回答"谁删了什么"

线上真正排查时,只有 deleted_at 不够。你还要知道谁删的、从哪个接口删的、一次删了多少条、有没有跨租户目标。日志建议至少包含这些字段:

json 复制代码
{
  "event": "admin_delete_customer",
  "actor": 501,
  "tenant_id": 1001,
  "perm": "crm:customer:delete",
  "target_ids": [101, 102],
  "affected": 2,
  "ip": "10.0.0.12",
  "result": "ok"
}

失败也要记。尤其是 403 和恢复冲突,这些日志能帮你判断是有人误操作、权限配置错了,还是前端页面漏了按钮状态。

json 复制代码
{
  "event": "admin_restore_customer",
  "actor": 501,
  "tenant_id": 1001,
  "target_id": 101,
  "result": "conflict",
  "reason": "mobile already used by active customer",
  "conflict_id": 238
}

代码生成器也要生成验收点

这也是我看后台代码生成器时最在意的地方。生成一个删除按钮很容易,难的是把"按钮、权限码、批量删除、软删除字段、唯一索引、恢复冲突"放进同一套验收里。

XYGo Admin 的生成器源码里已经能看到这些边界的影子,比如 server/internal/logic/gencodes/generate.go 里有 HasBatchDelHasDelHasMenu 这类模板开关,server/internal/middleware/admin_permission.go 负责后台 API 权限校验。它不能替你判断每张业务表的唯一索引怎么设计,但可以作为检查生成器边界的样本:生成出来的不只是页面,还有权限和批量操作这些容易出事的连接点。

项目源码在 GitHub 仓库。我更建议直接看上面两个源码路径,而不是只看 README 功能列表。本文的判断能不能落地,关键就藏在这些生成开关和中间件里。

上线前的最小检查表

  • 表结构:软删除字段、删除人、删除时间、更新时间是否齐;
  • 唯一索引:有效数据唯一,已删除数据不挡住重新创建;
  • 列表查询:默认只查 deleted_at IS NULL,回收站单独查已删除;
  • 删除接口:401、403、200 三条路径都要测;
  • 批量删除:必须带租户或组织边界;
  • 恢复接口:恢复前先查唯一字段冲突;
  • 审计日志:成功、失败、冲突都能查到操作者和目标 ID。

我的习惯是把删除功能当成"数据生命周期"来验,而不是当成一个按钮来验。页面能删只是第一步。能安全地删、能解释为什么删、能在不破坏唯一约束的情况下恢复,才算这个后台删除功能真的能上线。

相关推荐
想你依然心痛1 小时前
用MySQL玩转数据可视化:从SQL查询到动态图表的完整实战
sql·mysql·信息可视化
张人玉1 小时前
基于 Vue 3 + ECharts + Express + SQLite 构建的新能源汽车销量数据分析与可视化平台——新能源汽车销量数据分析系统
数据库·vue.js·sqlite·echarts
笨笨饿3 小时前
#102_Codex无在VSCold无法打开
java·c语言·数据库·笔记
向夏威夷 梦断明暄3 小时前
从架构特点到功能缺陷,重新认识分析型分布式数据库
数据库·分布式·架构
Mico183 小时前
MySQL 8.0.35 主从延迟模拟与解决
mysql
不知疲倦的仄仄4 小时前
MySQL/Read View快照/MVCC/串行化
java·数据库·mysql
向夏威夷 梦断明暄4 小时前
C# 弃元模式:从语法糖到性能利器的深度解析
服务器·数据库·c#
糖果店的幽灵4 小时前
大模型测评DeepEval快速入门-RAG指标详解
数据库·人工智能·langgraph·大模型测评·deepeval