package console import ( "errors" "strings" "time" "yunyan/comm" "yunyan/lego/sys/mysql" "yunyan/pb" "github.com/gin-gonic/gin" ) // ============================ 终端用户域 (user) ============================ // // 与设备域(console 主库)不同:终端用户住在【所选应用的业务库】里,所以这里的每个接口 // 都要先从 X-App-Id 解出应用、拿到该应用的业务库连接,再查它的 user 表。 // // 原来只有「按 UID 精确查一个人」,运营手上没有 UID 就什么也做不了。 // 现在补了列表(分页+条件筛选)与参数修改。 // userListPageMax 单页上限。列表带 COUNT(*),页太大在几十万用户的库上会很慢; // 真要导出应该另开导出接口,而不是把 page_size 调到几千。 const userListPageMax = 200 // buildUserListWhere 把筛选条件拼成 where 子句。 // // keyword 一栏多用:UID 精确、其余字段模糊。做成一栏是因为运营手上拿到的线索形态不定 // (客服转来的可能是手机号、可能是邮箱、也可能是用户自己报的昵称), // 让人先选「按什么查」再输入只是多一步。 func buildUserListWhere(req *userListReq, now int64) (string, []interface{}) { conds := make([]string, 0, 6) args := make([]interface{}, 0, 8) if kw := strings.TrimSpace(req.Keyword); kw != "" { like := "%" + kw + "%" conds = append(conds, "(uid = ? OR phone LIKE ? OR mail LIKE ? OR name LIKE ? OR account LIKE ? OR phonemac LIKE ?)") args = append(args, kw, like, like, like, like, like) } if req.State >= 0 { conds = append(conds, "`State` = ?") args = append(args, req.State) } // vip: 1=会员中(未过期) 0=非会员。按到期时间判定而不是 viplv—— // viplv 是等级,过期后并不会被清零,拿它判会把过期用户也算成会员。 switch req.Vip { case 1: conds = append(conds, "vipexptime > ?") args = append(args, now) case 0: conds = append(conds, "(vipexptime IS NULL OR vipexptime <= ?)") args = append(args, now) } if req.Idverified >= 0 { conds = append(conds, "idverified = ?") args = append(args, req.Idverified == 1) } if req.Start > 0 { conds = append(conds, "createtime >= ?") args = append(args, req.Start) } if req.End > 0 { conds = append(conds, "createtime < ?") args = append(args, req.End) } if len(conds) == 0 { return "1=1", args } return strings.Join(conds, " AND "), args } type userListReq struct { Keyword string `json:"keyword"` State int32 `json:"state"` // -1 不限;0 正常 1 禁用 2 已注销 Vip int32 `json:"vip"` // -1 不限;1 会员中 0 非会员 Idverified int32 `json:"idverified"` // -1 不限;1 已实名 0 未实名 Start int64 `json:"start"` // 注册时间区间 [start, end) End int64 `json:"end"` Page int `json:"page"` // 从 0 起 PageSize int `json:"page_size"` // 默认 20,上限 userListPageMax } // getUsers 终端用户列表:分页 + 条件筛选。 func (this *serverComp) getUsers(c *gin.Context) { appId := parseAppId(c.GetHeader("X-App-Id")) if !this.requireAppScope(c, appId) { return } conn, err := this.module.registry.getServiceDB(appId) if err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } // 三个「不限」标志位默认必须是 -1 而不是 0:0 是合法取值(正常状态 / 非会员 / 未实名), // 拿零值当「不限」会让不传参的默认查询悄悄只返回一部分人。 req := &userListReq{State: -1, Vip: -1, Idverified: -1} _ = c.ShouldBindJSON(req) if req.PageSize <= 0 { req.PageSize = 20 } if req.PageSize > userListPageMax { req.PageSize = userListPageMax } if req.Page < 0 { req.Page = 0 } where, args := buildUserListWhere(req, time.Now().Unix()) var total int64 if err := conn.Table(comm.TableUser).Where(where, args...).Count(&total).Error; err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } users := make([]*pb.DBUser, 0, req.PageSize) if total > 0 { if err := conn.Table(comm.TableUser). Where(where, args...). Order("createtime DESC"). Limit(req.PageSize).Offset(req.Page * req.PageSize). Find(&users).Error; err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } } for _, u := range users { u.Password = "" // 列表同样不下发密码 } writeOK(c, gin.H{"users": users, "total": total, "page": req.Page, "page_size": req.PageSize}) } // updateUser 修改终端用户的可运营参数。 // // ⚠️ 只放给超管/管理员。代理(Agent)不在此列——代理要发资源必须走 // api_giftuserresource,那条路会从代理自己的余额里扣;开放本接口等于让代理凭空造资源。 // // ⚠️ 只改白名单里的几个字段,用 Updates(map) 局部更新而不是整行 Save: // 整行写回会把并发中的登录时间、积分消耗等一起覆盖成后台读到的旧值。 func (this *serverComp) updateUser(c *gin.Context) { if idt := currentIdentity(c); idt != pb.Identity_Admin && idt != pb.Identity_Manager { writeErr(c, pb.ErrorCode_InsufficientPermissions, "仅超管/管理员可修改用户参数(代理请用「赠送资源点」)") return } appId := parseAppId(c.GetHeader("X-App-Id")) if !this.requireAppScope(c, appId) { return } conn, err := this.module.registry.getServiceDB(appId) if err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } // 指针字段:区分「没传这一项」与「传了 0」。用值类型会让「把积分清零」变成不可表达的操作。 var req struct { Uid string `json:"uid"` Name *string `json:"name"` State *int32 `json:"state"` Viplv *int32 `json:"viplv"` Vipexptime *int64 `json:"vipexptime"` Aichatintegral *int64 `json:"aichatintegral"` Tradeintegral *int64 `json:"tradeintegral"` Meetintegral *int64 `json:"meetintegral"` } _ = c.ShouldBindJSON(&req) uid := strings.TrimSpace(req.Uid) if uid == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "uid 必填") return } old := &pb.DBUser{} if err := conn.FindOne(comm.TableUser, old, "uid=?", uid); err != nil { if errors.Is(err, mysql.ErrNoDocuments) { writeErr(c, pb.ErrorCode_UserSessionNobeing, "未找到该用户") return } writeErr(c, pb.ErrorCode_DBError, err.Error()) return } updates := map[string]interface{}{} if req.Name != nil { updates["name"] = strings.TrimSpace(*req.Name) } if req.State != nil { switch *req.State { case int32(pb.UserState_Normal), int32(pb.UserState_Disabled), int32(pb.UserState_Cancelled): updates["State"] = *req.State default: writeErr(c, pb.ErrorCode_ReqParameterError, "状态仅支持 0(正常) / 1(禁用) / 2(已注销)") return } } if req.Viplv != nil { if *req.Viplv < 0 { writeErr(c, pb.ErrorCode_ReqParameterError, "VIP 等级不能为负") return } updates["viplv"] = *req.Viplv } if req.Vipexptime != nil { if *req.Vipexptime < 0 { writeErr(c, pb.ErrorCode_ReqParameterError, "VIP 到期时间不能为负") return } updates["vipexptime"] = *req.Vipexptime } // 三种积分是「当前余额」,直接设成绝对值。总额(total*)不动——那是累计发放量, // 属于流水统计口径,被后台改一次就再也对不上账了。 for k, v := range map[string]*int64{ "aichatintegral": req.Aichatintegral, "tradeintegral": req.Tradeintegral, "meetintegral": req.Meetintegral, } { if v == nil { continue } if *v < 0 { writeErr(c, pb.ErrorCode_ReqParameterError, "积分余额不能为负") return } updates[k] = *v } if len(updates) == 0 { writeErr(c, pb.ErrorCode_ReqParameterError, "没有要修改的字段") return } if err := conn.Table(comm.TableUser).Where("uid=?", uid).Updates(updates).Error; err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } writeOK(c, gin.H{"uid": uid, "updated": len(updates)}) }