427 lines
17 KiB
Go
427 lines
17 KiB
Go
package logic
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
|
||
// 测试进程不会执行 main.go 的驱动注册,这里显式引入 MySQL 驱动
|
||
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
|
||
"github.com/gogf/gf/v2/database/gdb"
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
"github.com/gogf/gf/v2/net/ghttp"
|
||
"github.com/gogf/gf/v2/os/gtime"
|
||
|
||
v1 "tool-api/api/admin/v1"
|
||
"tool-api/internal/consts"
|
||
)
|
||
|
||
// ============================================================================
|
||
// 软删除真实库集成测试(默认跳过;需真实 MySQL)
|
||
//
|
||
// 运行:$env:IT_DB=1; go test ./internal/logic/ -run TestSoftDelete -count=1 -v
|
||
//
|
||
// 说明:
|
||
// - 连的是 dev 库(默认 mysql:root:root@tcp(127.0.0.1:3306)/nl_toolbox,可用 IT_DB_LINK 覆盖)。
|
||
// 测试进程 cwd 是包目录,GoFrame 未必能找到 manifest/config,故这里显式 SetConfig。
|
||
// - 仅使用带 __probe 前缀 / 极大假 enterprise_id 的临时数据,结束即清理。
|
||
// - 若 users 缺 deleted_at/deleted_from 列或 employees 表不存在,测试会按 migrate 的定义补建,
|
||
// 与线上启动迁移一致(幂等)。
|
||
// ============================================================================
|
||
|
||
func requireIT(t *testing.T) {
|
||
t.Helper()
|
||
if os.Getenv("IT_DB") == "" {
|
||
t.Skip("set IT_DB=1 以运行真实库集成测试")
|
||
}
|
||
link := os.Getenv("IT_DB_LINK")
|
||
if link == "" {
|
||
link = "mysql:root:root@tcp(127.0.0.1:3306)/nl_toolbox"
|
||
}
|
||
gdb.SetConfig(gdb.Config{
|
||
"default": gdb.ConfigGroup{
|
||
gdb.ConfigNode{Link: link},
|
||
},
|
||
})
|
||
}
|
||
|
||
// ensureUserSoftDeleteCols 确保 users 具备 deleted_at / deleted_from 列(与 migrateColumns 一致)。
|
||
func ensureUserSoftDeleteCols(ctx context.Context, t *testing.T) {
|
||
t.Helper()
|
||
cols := []struct{ name, ddl string }{
|
||
{"deleted_at", "ALTER TABLE `users` ADD COLUMN `deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除'"},
|
||
{"deleted_from", "ALTER TABLE `users` ADD COLUMN `deleted_from` text COMMENT '软删前唯一键原值(JSON),便于恢复'"},
|
||
}
|
||
for _, c := range cols {
|
||
exists, err := columnExists(ctx, "users", c.name)
|
||
if err != nil {
|
||
t.Fatalf("columnExists users.%s: %v", c.name, err)
|
||
}
|
||
if !exists {
|
||
if _, err = g.DB().Exec(ctx, c.ddl); err != nil {
|
||
t.Fatalf("add users.%s: %v", c.name, err)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ensureEmployeesTable 确保 employees 表存在(与 migrateTables 的 DDL 一致)。
|
||
func ensureEmployeesTable(ctx context.Context, t *testing.T) {
|
||
t.Helper()
|
||
ddl := "CREATE TABLE IF NOT EXISTS `employees` (" +
|
||
"`id` bigint unsigned NOT NULL AUTO_INCREMENT," +
|
||
"`enterprise_id` bigint NOT NULL," +
|
||
"`name` varchar(64) NOT NULL," +
|
||
"`phone` varchar(20) NOT NULL," +
|
||
"`dept` varchar(64) NOT NULL DEFAULT ''," +
|
||
"`remark` varchar(255) NOT NULL DEFAULT ''," +
|
||
"`status` tinyint NOT NULL DEFAULT 1," +
|
||
"`deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除'," +
|
||
"`deleted_from` text COMMENT '软删前 phone 原值(JSON),便于恢复'," +
|
||
"`created_at` datetime DEFAULT NULL,`updated_at` datetime DEFAULT NULL," +
|
||
"PRIMARY KEY (`id`)," +
|
||
"UNIQUE KEY `uk_ent_phone` (`enterprise_id`,`phone`)," +
|
||
"KEY `idx_ent_name` (`enterprise_id`,`name`)" +
|
||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企业员工库(跨活动复用)'"
|
||
if _, err := g.DB().Exec(ctx, ddl); err != nil {
|
||
t.Fatalf("ensure employees: %v", err)
|
||
}
|
||
}
|
||
|
||
// ensureToolsSoftDeleteCol 确保 tools 具备 deleted_at 列(与 migrateColumns 一致;dev 库未必跑过迁移)。
|
||
func ensureToolsSoftDeleteCol(ctx context.Context, t *testing.T) {
|
||
t.Helper()
|
||
exists, err := columnExists(ctx, "tools", "deleted_at")
|
||
if err != nil {
|
||
t.Fatalf("columnExists tools.deleted_at: %v", err)
|
||
}
|
||
if !exists {
|
||
if _, err = g.DB().Exec(ctx,
|
||
"ALTER TABLE `tools` ADD COLUMN `deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除'"); err != nil {
|
||
t.Fatalf("add tools.deleted_at: %v", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestSoftDeleteUsers_ReRegisterSucceeds 软删用户后:未删列表看不到 + 原 openid/用户名释放 +
|
||
// 同 openid/用户名可重新注册 + 重复软删幂等。
|
||
func TestSoftDeleteUsers_ReRegisterSucceeds(t *testing.T) {
|
||
requireIT(t)
|
||
ctx := context.Background()
|
||
ensureUserSoftDeleteCols(ctx, t)
|
||
|
||
const openid = "__probe_openid_softdel__"
|
||
const username = "__probe_user_softdel__"
|
||
var probeId int64
|
||
cleanup := func() {
|
||
_, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE openid=? OR username=?", openid, username)
|
||
if probeId != 0 {
|
||
_, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE id=?", probeId)
|
||
}
|
||
}
|
||
cleanup()
|
||
defer cleanup()
|
||
|
||
id, err := g.Model(consts.TableUsers).Data(g.Map{
|
||
"openid": openid, "username": username, "nickname": "probe",
|
||
"level_key": consts.DefaultLevelKey, "status": 1,
|
||
"created_at": gtime.Now(), "updated_at": gtime.Now(),
|
||
}).InsertAndGetId()
|
||
if err != nil {
|
||
t.Fatalf("insert probe user: %v", err)
|
||
}
|
||
probeId = id
|
||
|
||
if err = softDeleteUsers(ctx, []int64{id}); err != nil {
|
||
t.Fatalf("softDeleteUsers: %v", err)
|
||
}
|
||
// 1) 过滤:未删列表(deleted_at IS NULL)查不到
|
||
if n, _ := g.Model(consts.TableUsers).Where("openid", openid).WhereNull("deleted_at").Count(); n != 0 {
|
||
t.Fatalf("软删用户仍出现在未删列表: n=%d", n)
|
||
}
|
||
// 2) 墓碑化:原 openid / username 已释放(不过滤也查不到该原值)
|
||
if n, _ := g.Model(consts.TableUsers).Where("openid", openid).Count(); n != 0 {
|
||
t.Fatalf("原 openid 仍被占用: n=%d", n)
|
||
}
|
||
if n, _ := g.Model(consts.TableUsers).Where("username", username).Count(); n != 0 {
|
||
t.Fatalf("原 username 仍被占用: n=%d", n)
|
||
}
|
||
// 3) 重新注册同 openid + username 必须成功
|
||
if _, err = g.Model(consts.TableUsers).Data(g.Map{
|
||
"openid": openid, "username": username, "nickname": "probe2",
|
||
"level_key": consts.DefaultLevelKey, "status": 1,
|
||
"created_at": gtime.Now(), "updated_at": gtime.Now(),
|
||
}).Insert(); err != nil {
|
||
t.Fatalf("同 openid/username 重新注册失败: %v", err)
|
||
}
|
||
// 4) 幂等:再次软删同一已删 id 不报错
|
||
if err = softDeleteUsers(ctx, []int64{id}); err != nil {
|
||
t.Fatalf("重复软删不幂等: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestSoftDeleteEmployees_ReAddSucceeds 软删员工后:同企业同手机号可重新录入(uk_ent_phone 复合唯一)+ 幂等。
|
||
func TestSoftDeleteEmployees_ReAddSucceeds(t *testing.T) {
|
||
requireIT(t)
|
||
ctx := context.Background()
|
||
ensureEmployeesTable(ctx, t)
|
||
|
||
const entId int64 = 9223372036854775 // 极大假企业 id,避开真实数据
|
||
const phone = "__probe_phone__"
|
||
cleanup := func() {
|
||
_, _ = g.DB().Exec(ctx, "DELETE FROM employees WHERE enterprise_id=?", entId)
|
||
}
|
||
cleanup()
|
||
defer cleanup()
|
||
|
||
id, err := g.Model(consts.TableEmployees).Data(g.Map{
|
||
"enterprise_id": entId, "name": "probe", "phone": phone, "status": 1,
|
||
"created_at": gtime.Now(), "updated_at": gtime.Now(),
|
||
}).InsertAndGetId()
|
||
if err != nil {
|
||
t.Fatalf("insert probe employee: %v", err)
|
||
}
|
||
if err = softDeleteEmployees(ctx, []int64{id}); err != nil {
|
||
t.Fatalf("softDeleteEmployees: %v", err)
|
||
}
|
||
// 过滤
|
||
if n, _ := g.Model(consts.TableEmployees).Where("enterprise_id", entId).Where("phone", phone).WhereNull("deleted_at").Count(); n != 0 {
|
||
t.Fatalf("软删员工仍在未删列表: n=%d", n)
|
||
}
|
||
// 同企业同手机号重新录入必须成功
|
||
if _, err = g.Model(consts.TableEmployees).Data(g.Map{
|
||
"enterprise_id": entId, "name": "probe2", "phone": phone, "status": 1,
|
||
"created_at": gtime.Now(), "updated_at": gtime.Now(),
|
||
}).Insert(); err != nil {
|
||
t.Fatalf("同企业同手机号重新录入失败: %v", err)
|
||
}
|
||
// 幂等
|
||
if err = softDeleteEmployees(ctx, []int64{id}); err != nil {
|
||
t.Fatalf("重复软删不幂等: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestSoftDeleteUsers_UsernameSquattingBlocked 抢注通道关闭的真实库验证(回归修复前的漏洞):
|
||
//
|
||
// 修复前:软删 victim 会把 username 改成短墓碑 "#del<victimId>",其长度 5-20,
|
||
// 落在 username 的合法区间(required|length:3,32)内,**可被攻击者提前抢注**。
|
||
// 抢注成功后,软删 victim 就会撞 uk_username → AdminUserBatch 整体报错 → 整个批量删除失败。
|
||
//
|
||
// 本测试:先让攻击者注册 username="#del<victimId>"(修复前这能插进去,正说明漏洞真实存在),
|
||
// 再软删 victim;断言软删仍成功(长墓碑 >32,攻击者抢不到),
|
||
// 且 victim.username 等于长墓碑、deleted_from 保存了原值。
|
||
func TestSoftDeleteUsers_UsernameSquattingBlocked(t *testing.T) {
|
||
requireIT(t)
|
||
ctx := context.Background()
|
||
ensureUserSoftDeleteCols(ctx, t)
|
||
|
||
const victimOpenid = "__probe_victim_openid_squat__"
|
||
const victimName = "__probe_victim_name_squat__"
|
||
const attackerOpenid = "__probe_attacker_openid_squat__"
|
||
var victimId, attackerId int64
|
||
cleanup := func() {
|
||
_, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE openid IN (?,?)", victimOpenid, attackerOpenid)
|
||
_, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE username=?", victimName)
|
||
// victim 被软删后 openid/username 已墓碑化,按主键删;同时按 deleted_from 扫历史残留探测行
|
||
if victimId != 0 {
|
||
_, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE id=?", victimId)
|
||
}
|
||
if attackerId != 0 {
|
||
_, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE id=?", attackerId)
|
||
}
|
||
_, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE deleted_from LIKE ?", "%"+victimOpenid+"%")
|
||
}
|
||
cleanup()
|
||
defer cleanup()
|
||
|
||
// 1) 先插 victim,拿到 victimId(短墓碑名依赖它)
|
||
id, err := g.Model(consts.TableUsers).Data(g.Map{
|
||
"openid": victimOpenid, "username": victimName, "nickname": "victim",
|
||
"level_key": consts.DefaultLevelKey, "status": 1,
|
||
"created_at": gtime.Now(), "updated_at": gtime.Now(),
|
||
}).InsertAndGetId()
|
||
if err != nil {
|
||
t.Fatalf("insert victim: %v", err)
|
||
}
|
||
victimId = id
|
||
|
||
// 2) 攻击者抢注「修复前会生成的短墓碑」——修复前它是合法且能插入的
|
||
squatName := tombstoneShort(victimId)
|
||
if len(squatName) < 3 || len(squatName) > 32 {
|
||
t.Fatalf("抢注名 %q 长度 %d 不在合法区间,测试前提不成立", squatName, len(squatName))
|
||
}
|
||
attackerId, err = g.Model(consts.TableUsers).Data(g.Map{
|
||
"openid": attackerOpenid, "username": squatName, "nickname": "attacker",
|
||
"level_key": consts.DefaultLevelKey, "status": 1,
|
||
"created_at": gtime.Now(), "updated_at": gtime.Now(),
|
||
}).InsertAndGetId()
|
||
if err != nil {
|
||
t.Fatalf("攻击者抢注短墓碑名 %q 失败(测试前提不成立): %v", squatName, err)
|
||
}
|
||
|
||
// 3) 软删 victim:修复后必须成功(长墓碑不与攻击者占用的短墓碑相撞)
|
||
if err = softDeleteUsers(ctx, []int64{victimId}); err != nil {
|
||
t.Fatalf("软删 victim 失败(抢注通道未关闭?): %v", err)
|
||
}
|
||
|
||
// 4) 直接查库(raw SQL,绕过 ORM 软删过滤):victim.username 必须是长墓碑
|
||
rows, err := g.DB().GetAll(ctx, "SELECT username, deleted_from, deleted_at FROM users WHERE id=?", victimId)
|
||
if err != nil {
|
||
t.Fatalf("query victim: %v", err)
|
||
}
|
||
if len(rows) != 1 {
|
||
t.Fatalf("victim 行数=%d 期望 1", len(rows))
|
||
}
|
||
if got := rows[0]["username"].String(); got != tombstoneLong(victimId) {
|
||
t.Fatalf("victim.username=%q 期望长墓碑 %q", got, tombstoneLong(victimId))
|
||
}
|
||
// 5) deleted_from 保存原值,便于恢复
|
||
if df := rows[0]["deleted_from"].String(); !strings.Contains(df, victimName) {
|
||
t.Fatalf("deleted_from=%q 未保存原 username %q", df, victimName)
|
||
}
|
||
// 6) ORM 软删过滤:Model 查询看不到已软删 victim(GoFrame v2.10 soft-time 自动过滤)
|
||
if n, _ := g.Model(consts.TableUsers).Where("id", victimId).Count(); n != 0 {
|
||
t.Fatalf("Model 仍能看到已软删 victim: n=%d", n)
|
||
}
|
||
// 7) 原 username 已释放:未删用户里查不到该原值
|
||
if n, _ := g.Model(consts.TableUsers).Where("username", victimName).Count(); n != 0 {
|
||
t.Fatalf("原 username 仍被占用: n=%d", n)
|
||
}
|
||
}
|
||
|
||
// TestSoftDeleteBatchDelete_Idempotent 幂等契约(回归 existingIdSet 被框架软删过滤的真 bug):
|
||
// 对同一批 id **连续 delete 两次**,第二次仍须 success=N / failed=0。
|
||
//
|
||
// 修复前:existingIdSet 用 Model 查询,被框架自动追加 `deleted_at IS NULL` → 已软删 id 查不出
|
||
//
|
||
// → 落进 missing → 返回 failed,违反「重复删除已删 id 计成功」的冻结契约。
|
||
//
|
||
// 修复后:existingIdSet 加 `.Unscoped()`,软删行照常可见 → 计入 ok。
|
||
func TestSoftDeleteBatchDelete_Idempotent(t *testing.T) {
|
||
requireIT(t)
|
||
// AdminUserBatch 内部会 WriteAudit → CtxAdminId → g.RequestFromCtx(ctx),需要对非 nil 的
|
||
// *ghttp.Request(否则 GetCtxVar 在 nil 上解引用 panic)。这里构造一个最小请求上下文,
|
||
// 使该链路可跑(审计是旁路,写失败也仅告警)。
|
||
req := &ghttp.Request{Request: &http.Request{}}
|
||
ctx := req.Context()
|
||
ensureUserSoftDeleteCols(ctx, t)
|
||
ensureEmployeesTable(ctx, t)
|
||
ensureToolsSoftDeleteCol(ctx, t)
|
||
|
||
// 审计是旁路(WriteAudit 失败仅告警),但若该表存在会留下记录;清理本次产生、admin_id=0 的批量删除审计
|
||
defer func() {
|
||
_, _ = g.DB().Exec(ctx,
|
||
"DELETE FROM `"+consts.TableAdminAuditLog+"` WHERE admin_id=0 AND action IN ('user.batch_delete','tool.batch_delete','employee.batch_delete')")
|
||
}()
|
||
|
||
// ---------- users(existingIdSet + softDeleteUsers 路径)----------
|
||
const uOpenid = "__probe_idem_openid__"
|
||
const uName = "__probe_idem_name__"
|
||
var uid int64
|
||
uCleanup := func() {
|
||
_, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE openid=? OR username=?", uOpenid, uName)
|
||
if uid != 0 {
|
||
_, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE id=?", uid)
|
||
}
|
||
_, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE deleted_from LIKE ?", "%"+uOpenid+"%")
|
||
}
|
||
uCleanup()
|
||
defer uCleanup()
|
||
|
||
uid, err := g.Model(consts.TableUsers).Data(g.Map{
|
||
"openid": uOpenid, "username": uName, "nickname": "idem",
|
||
"level_key": consts.DefaultLevelKey, "status": 1,
|
||
"created_at": gtime.Now(), "updated_at": gtime.Now(),
|
||
}).InsertAndGetId()
|
||
if err != nil {
|
||
t.Fatalf("insert probe user: %v", err)
|
||
}
|
||
|
||
ur1, err := AdminUserBatch(ctx, &v1.UserBatchReq{Ids: []int64{uid}, Action: "delete"})
|
||
if err != nil {
|
||
t.Fatalf("user 第1次 delete: %v", err)
|
||
}
|
||
if ur1.Success != 1 || ur1.Failed != 0 {
|
||
t.Fatalf("user 第1次 delete 期望 success=1/failed=0,实得 %d/%d failures=%+v", ur1.Success, ur1.Failed, ur1.Failures)
|
||
}
|
||
ur2, err := AdminUserBatch(ctx, &v1.UserBatchReq{Ids: []int64{uid}, Action: "delete"})
|
||
if err != nil {
|
||
t.Fatalf("user 第2次 delete: %v", err)
|
||
}
|
||
if ur2.Success != 1 || ur2.Failed != 0 {
|
||
t.Fatalf("user 重复 delete 必须幂等 success=1/failed=0,实得 %d/%d failures=%+v(existingIdSet 漏了 Unscoped?)",
|
||
ur2.Success, ur2.Failed, ur2.Failures)
|
||
}
|
||
|
||
// ---------- tools(existingIdSet + softDeleteSimple 路径)----------
|
||
const toolKey = "__probe_idem_tool__"
|
||
var tid int64
|
||
tCleanup := func() {
|
||
_, _ = g.DB().Exec(ctx, "DELETE FROM tools WHERE tool_key=?", toolKey)
|
||
if tid != 0 {
|
||
_, _ = g.DB().Exec(ctx, "DELETE FROM tools WHERE id=?", tid)
|
||
}
|
||
}
|
||
tCleanup()
|
||
defer tCleanup()
|
||
|
||
tid, err = g.Model(consts.TableTools).Data(g.Map{
|
||
"tool_key": toolKey, "module_key": "image", "name": "idem-probe",
|
||
"icon": "picture", "description": "probe", "sort": 999, "is_hot": 0,
|
||
"created_at": gtime.Now(), "updated_at": gtime.Now(),
|
||
}).InsertAndGetId()
|
||
if err != nil {
|
||
t.Fatalf("insert probe tool: %v", err)
|
||
}
|
||
|
||
tr1, err := AdminToolsBatch(ctx, &v1.ToolsBatchReq{Ids: []int64{tid}, Action: "delete"})
|
||
if err != nil {
|
||
t.Fatalf("tool 第1次 delete: %v", err)
|
||
}
|
||
if tr1.Success != 1 || tr1.Failed != 0 {
|
||
t.Fatalf("tool 第1次 delete 期望 success=1/failed=0,实得 %d/%d", tr1.Success, tr1.Failed)
|
||
}
|
||
tr2, err := AdminToolsBatch(ctx, &v1.ToolsBatchReq{Ids: []int64{tid}, Action: "delete"})
|
||
if err != nil {
|
||
t.Fatalf("tool 第2次 delete: %v", err)
|
||
}
|
||
if tr2.Success != 1 || tr2.Failed != 0 {
|
||
t.Fatalf("tool 重复 delete 必须幂等 success=1/failed=0,实得 %d/%d failures=%+v", tr2.Success, tr2.Failed, tr2.Failures)
|
||
}
|
||
|
||
// ---------- employees(existingIdSet + softDeleteEmployees 路径)----------
|
||
const entId int64 = 9223372036854774 // 极大假企业 id,避开真实数据
|
||
const ePhone = "__probe_idem_phone__"
|
||
var eid int64
|
||
eCleanup := func() {
|
||
_, _ = g.DB().Exec(ctx, "DELETE FROM employees WHERE enterprise_id=?", entId)
|
||
}
|
||
eCleanup()
|
||
defer eCleanup()
|
||
|
||
eid, err = g.Model(consts.TableEmployees).Data(g.Map{
|
||
"enterprise_id": entId, "name": "idem-probe", "phone": ePhone, "status": 1,
|
||
"created_at": gtime.Now(), "updated_at": gtime.Now(),
|
||
}).InsertAndGetId()
|
||
if err != nil {
|
||
t.Fatalf("insert probe employee: %v", err)
|
||
}
|
||
|
||
er1, err := AdminEmployeeBatch(ctx, &v1.EmployeeBatchReq{Ids: []int64{eid}, Action: "delete"})
|
||
if err != nil {
|
||
t.Fatalf("employee 第1次 delete: %v", err)
|
||
}
|
||
if er1.Success != 1 || er1.Failed != 0 {
|
||
t.Fatalf("employee 第1次 delete 期望 success=1/failed=0,实得 %d/%d", er1.Success, er1.Failed)
|
||
}
|
||
er2, err := AdminEmployeeBatch(ctx, &v1.EmployeeBatchReq{Ids: []int64{eid}, Action: "delete"})
|
||
if err != nil {
|
||
t.Fatalf("employee 第2次 delete: %v", err)
|
||
}
|
||
if er2.Success != 1 || er2.Failed != 0 {
|
||
t.Fatalf("employee 重复 delete 必须幂等 success=1/failed=0,实得 %d/%d failures=%+v", er2.Success, er2.Failed, er2.Failures)
|
||
}
|
||
}
|