This commit is contained in:
小蚂蚁云
2026-10-26 08:46:20 +08:00
parent 276740924a
commit 69f5c1767d
26 changed files with 259 additions and 121 deletions
+1 -1
View File
@@ -241,7 +241,7 @@ func SerializeMapOnRead(ctx context.Context, data map[string]any, fieldDict map[
// LogTypeText 操作日志类型转中文名
//
// 该字段存储的是注解常量值(1=新增…40=解绑),与字典编码体系不一致,
// 该字段存储的是标签常量值(1=新增…40=解绑),与字典编码体系不一致,
// 故使用静态映射表而非字典;未知值回退原数字。
func LogTypeText(v int) string {
if n, ok := consts.LogTypeNames[v]; ok {
+48 -1
View File
@@ -49,6 +49,7 @@ import (
"easygoadmin/api/v1"
"easygoadmin/internal/dao"
"easygoadmin/internal/service"
"easygoadmin/utility/upload"
)
// ============================== 登录日志业务实现 ==============================
@@ -72,6 +73,44 @@ var loginLogSpec = ModuleSpec[v1.LoginLogItem, v1.LoginLogItem, v1.LoginLogDetai
EqFields: []string{"log_type", "status"},
OrderBy: "id DESC",
SerializeMaps: loginLogSerializeMaps,
// 头像按登录账号用户名回查补齐(失败登录无对应用户时留空)
AfterPageList: func(ctx context.Context, list []*v1.LoginLogItem, req any) error {
return fillLoginLogAvatars(ctx, list)
},
AfterDetail: func(ctx context.Context, item *v1.LoginLogDetailRes, req any) error {
return fillLoginLogAvatars(ctx, []*v1.LoginLogItem{&item.LoginLogItem})
},
}
// fillLoginLogAvatars 按登录账号用户名批量查询并回填头像
//
// 日志表不冗余存头像:读侧将用户名去重后一次 IN 查询用户表的
// username/avatar 相对路径,经 upload.GetFileUrl 补全访问域名;
// 查询失败原样上抛。无有效用户名或用户不存在(登录失败记录)时留空。
func fillLoginLogAvatars(ctx context.Context, items []*v1.LoginLogItem) error {
nameMap := map[string]bool{}
names := make([]string, 0, len(items))
for _, it := range items {
if it.Username != "" && !nameMap[it.Username] {
nameMap[it.Username] = true
names = append(names, it.Username)
}
}
if len(names) == 0 {
return nil
}
rows, err := dao.User.Ctx(ctx).WhereIn("username", names).Fields("username, avatar").All()
if err != nil {
return err
}
avatarMap := make(map[string]string, len(rows))
for _, r := range rows {
avatarMap[r["username"].String()] = upload.GetFileUrl(ctx, r["avatar"].String())
}
for i := range items {
items[i].Avatar = avatarMap[items[i].Username]
}
return nil
}
func init() {
@@ -95,7 +134,15 @@ func (s *sLoginLog) List(ctx context.Context, req *v1.LoginLogListReq) (res v1.L
if err != nil {
return nil, err
}
return derefSlice(list), nil
rows := derefSlice(list)
ptrs := make([]*v1.LoginLogItem, len(rows))
for i := range rows {
ptrs[i] = &rows[i]
}
if err := fillLoginLogAvatars(ctx, ptrs); err != nil {
return nil, err
}
return rows, nil
}
// Detail 登录日志详情
+44 -6
View File
@@ -48,6 +48,7 @@ import (
"easygoadmin/internal/dao"
"easygoadmin/internal/middleware"
"easygoadmin/internal/service"
"easygoadmin/utility/upload"
)
// ============================== 操作日志业务实现 ==============================
@@ -70,19 +71,50 @@ var operLogSpec = ModuleSpec[v1.OperLogItem, v1.OperLogItem, v1.OperLogDetailRes
EqFields: []string{"type", "status"},
OrderBy: "id DESC",
SerializeMaps: operLogSerializeMaps,
// typeText 走静态映射(注解常量值,与字典体系无关)
// typeText 走静态映射(标签常量值,与字典体系无关);头像按操作人用户名回查补齐
AfterPageList: func(ctx context.Context, list []*v1.OperLogItem, req any) error {
for _, item := range list {
item.TypeText = LogTypeText(item.Type)
}
return nil
return fillOperLogAvatars(ctx, list)
},
AfterDetail: func(ctx context.Context, item *v1.OperLogDetailRes, req any) error {
item.TypeText = LogTypeText(item.Type)
return nil
return fillOperLogAvatars(ctx, []*v1.OperLogItem{&item.OperLogItem})
},
}
// fillOperLogAvatars 按操作人用户名批量查询并回填头像
//
// 日志表只落 create_user(用户名),头像不冗余存储:读侧将用户名去重后
// 一次 IN 查询用户表的 username/avatar 相对路径,经 upload.GetFileUrl
// 补全访问域名;查询失败原样上抛。无有效用户名(未认证请求)时直接返回。
func fillOperLogAvatars(ctx context.Context, items []*v1.OperLogItem) error {
nameMap := map[string]bool{}
names := make([]string, 0, len(items))
for _, it := range items {
if it.CreateUser != "" && !nameMap[it.CreateUser] {
nameMap[it.CreateUser] = true
names = append(names, it.CreateUser)
}
}
if len(names) == 0 {
return nil
}
rows, err := dao.User.Ctx(ctx).WhereIn("username", names).Fields("username, avatar").All()
if err != nil {
return err
}
avatarMap := make(map[string]string, len(rows))
for _, r := range rows {
avatarMap[r["username"].String()] = upload.GetFileUrl(ctx, r["avatar"].String())
}
for i := range items {
items[i].Avatar = avatarMap[items[i].CreateUser]
}
return nil
}
func init() {
service.RegisterOperLog(&sOperLog{})
// 操作日志中间件落库接线(异步批量,见 middleware/operation_log.go)
@@ -131,10 +163,16 @@ func (s *sOperLog) List(ctx context.Context, req *v1.OperLogListReq) (res v1.Ope
if err != nil {
return nil, err
}
for _, item := range list {
item.TypeText = LogTypeText(item.Type)
rows := derefSlice(list)
ptrs := make([]*v1.OperLogItem, len(rows))
for i := range rows {
rows[i].TypeText = LogTypeText(rows[i].Type)
ptrs[i] = &rows[i]
}
return derefSlice(list), nil
if err := fillOperLogAvatars(ctx, ptrs); err != nil {
return nil, err
}
return rows, nil
}
// Detail 操作日志详情
+40 -68
View File
@@ -55,7 +55,6 @@ import (
"easygoadmin/internal/dao"
"easygoadmin/internal/service"
"easygoadmin/utility/cache"
"easygoadmin/utility/crypto"
)
// ============================== 租户业务实现 ==============================
@@ -307,80 +306,53 @@ func (s *sTenant) Add(ctx context.Context, req *v1.TenantAddReq) (res *v1.IdRes,
return &v1.IdRes{Id: idv}, nil
}
// Account 创建租户账号
// Account 添加租户账号
//
// 同时创建租户及其初始管理员账号;
// bcrypt 属 CPU 密集操作,放在事务外预计算,保持短事务
// 在指定租户(req.TenantId)下创建用户账号,返回目标租户ID、租户编码与新用户ID。
// 平台级专属:拦截租户侧身份——本操作按请求显式指定的目标租户写入,
// 且唯一性校验/用户数上限的查询链依赖平台视角(无租户收敛)。
// 目标租户不存在返回 CodeTenantNotFound;账号唯一性、用户数上限、编码生成、
// 密码加盐、头像转正、角色关联复用用户新增链路(sUser.addWithTenant)。
func (s *sTenant) Account(ctx context.Context, req *v1.TenantAccountReq) (res *v1.TenantAccountRes, err error) {
// 租户:自增回填(临时编码占位 → T%04d 修正)
tempCode := fmt.Sprintf("T%010d", time.Now().UnixNano()%10000000000)
tData := map[string]any{
"name": req.Name,
"code": tempCode,
"contact": req.Contact,
"mobile": req.Mobile,
"email": req.Email,
"address": req.Address,
"status": 1,
"max_user_num": req.MaxUserNum,
"note": req.Note,
if !consts.IsPlatform(ctx) {
return nil, gerror.NewCode(consts.CodeForbidden)
}
if req.ExpireTime != "" {
if t := gtime.New(req.ExpireTime); t != nil {
tData["expire_time"] = t
}
// 目标租户存在性校验(同时取编码供响应回传)
one, e := dao.Tenant.Ctx(ctx).Where("id", req.TenantId).Fields("code").One()
if e != nil {
return nil, e
}
// bcrypt 属 CPU 密集操作,放在事务外预计算(短事务纪律)
salt := crypto.GenerateSalt()
hashed, err := crypto.HashPassword(req.Password, salt)
if len(one) == 0 {
return nil, gerror.NewCode(consts.CodeTenantNotFound)
}
// 用户表单字段转用户新增入参,按目标租户落库
idRes, err := (&sUser{}).addWithTenant(ctx, req.TenantId, &v1.UserAddReq{
Username: req.Username,
Password: req.Password,
Realname: req.Realname,
Gender: req.Gender,
Mobile: req.Mobile,
Email: req.Email,
Roles: req.Roles,
DeptId: req.DeptId,
LevelId: req.LevelId,
PositionId: req.PositionId,
City: req.City,
Address: req.Address,
Avatar: req.Avatar,
Intro: req.Intro,
Status: req.Status,
Sort: req.Sort,
Note: req.Note,
})
if err != nil {
return nil, err
}
realname := req.Realname
if realname == "" {
realname = req.Username
}
// 租户 + 初始管理员三段写入保持原子性(全部经 txCtx)
var tenantId, uid int64
var code string
if err = WithTrans(ctx, func(txCtx context.Context) error {
tResult, e := dao.Tenant.Ctx(txCtx).Data(tData).Insert()
if e != nil {
return e
}
tenantId, _ = tResult.LastInsertId()
code = fmt.Sprintf("T%04d", tenantId)
if _, e = dao.Tenant.Ctx(txCtx).Where("id", tenantId).Data(map[string]any{"code": code}).Update(); e != nil {
return e
}
// 初始管理员(tenant_id 由 hooks 自动填充)
tenantCtx := consts.WithTenantID(txCtx, int(tenantId))
uResult, e := dao.User.Ctx(tenantCtx).Data(map[string]any{
"username": req.Username,
"code": fmt.Sprintf("%s-000001", code), // 首个用户序号 000001
"password": hashed,
"salt": salt,
"realname": realname,
"mobile": req.Mobile,
"email": req.Email,
"province_code": "",
"city_code": "",
"district_code": "",
"street_code": "",
"address": "",
"status": 1,
"tenant_id": int(tenantId),
}).Insert()
if e != nil {
return e
}
uid, _ = uResult.LastInsertId()
return nil
}); err != nil {
return nil, err
}
return &v1.TenantAccountRes{TenantId: tenantId, Code: code, UserId: uid}, nil
return &v1.TenantAccountRes{
TenantId: int64(req.TenantId),
Code: one["code"].String(),
UserId: idRes.Id,
}, nil
}
// Users 查询租户用户列表
+13 -2
View File
@@ -277,9 +277,20 @@ func (s *sUser) Detail(ctx context.Context, req *v1.UserDetailReq) (res *v1.User
// Add 新增用户
//
// 用户名与手机号租户内唯一,校验租户用户上限,自动生成用户编码并写入 user_role
// 入参 *v1.UserAddReq;返回新记录ID供前端刷新定位。
// 租户取当前登录租户,落库链路见 addWithTenant。
func (s *sUser) Add(ctx context.Context, req *v1.UserAddReq) (res *v1.IdRes, err error) {
tenantId := consts.GetTenantID(ctx)
return s.addWithTenant(ctx, consts.GetTenantID(ctx), req)
}
// addWithTenant 在指定租户下创建用户(Add 与租户账号添加的共用实现)
//
// tenantId 为目标租户ID:Add 传当前登录租户,租户账号添加(Tenant.Account)
// 传请求携带的目标租户。顺序契约:用户名/手机号租户内唯一校验 → 租户用户数
// 上限校验 → 生成用户编码 → 密码双重加盐 → 组装落库 map → 头像 temp 转正 →
// 写入 gin_user → 重建角色关联;重复返回 CodeRecordExists,超限返回
// CodeTenantUserLimit,查询/加密错误原样上抛。
func (s *sUser) addWithTenant(ctx context.Context, tenantId int, req *v1.UserAddReq) (res *v1.IdRes, err error) {
if exists, e := s.usernameExists(ctx, req.Username, tenantId, 0); e != nil {
return nil, e
} else if exists {