package console
import (
"fmt"
"strings"
"time"
"yunyan/comm"
"yunyan/pb"
"yunyan/sys/email"
"github.com/gin-gonic/gin"
)
// 后台账号管理 handlers(仅超管可访问,路由已用 requireIdentity(Admin) 守卫)。
// 角色 Identity:1 超管 / 2 管理员 / 3 代理商 / 4 运营。账号存 console 主库 console_account 表,
// 密码 bcrypt 哈希。引导超管(yaml AdminAccount)不入表,故列表里看不到,也不能被改/删。
// validIdentity 限定可分配的角色取值(1~5)。
func validIdentity(idt pb.Identity) bool {
return idt >= pb.Identity_Admin && idt <= pb.Identity_Dealer
}
// identityHint 角色取值说明,供参数校验的错误信息复用。
const identityHint = "角色非法(1超管/2管理员/3品牌商(代理)/4运营/5渠道商)"
// validChannelBinding 校验品牌商/渠道商账号的归属绑定是否自洽:
// - 品牌商账号(3):必须绑定品牌商,且不绑定渠道商(它管的是名下全部渠道商);
// - 渠道商账号(5):必须同时绑定品牌商与渠道商,且该渠道商确实归属该品牌商;
// - 其余角色:两者都必须留空,避免误配出「超管绑了某品牌商」这种矛盾状态。
//
// 返回错误说明(空串=通过)。
func validChannelBinding(idt pb.Identity, brandid uint32, channelid string) string {
switch idt {
case pb.Identity_Agent:
if brandid == 0 {
return "品牌商账号必须绑定品牌商"
}
if channelid != "" {
return "品牌商账号不能绑定渠道商(它可见名下全部渠道商)"
}
case pb.Identity_Dealer:
if brandid == 0 || channelid == "" {
return "渠道商账号必须同时绑定品牌商与渠道商"
}
ch, err := dvChannel(consoleDeviceConn(), channelid)
if err != nil {
return "渠道商不存在: " + channelid
}
if ch.Brandid != brandid {
return "渠道商不属于所选品牌商: " + channelid
}
default:
if brandid != 0 || channelid != "" {
return "只有品牌商账号/渠道商账号才能绑定品牌商与渠道商"
}
}
return ""
}
// canCreateBoundAccount 是否可在新增品牌商/渠道商时顺手开账号:超管 + 管理员。
// 比 /console/api/accounts/*(仅超管的通用账号管理)宽一档——这里只能建「绑定到刚建的
// 品牌商/渠道商」这一种账号,角色与绑定都由服务端定死,管理员建品牌商时不必再找超管开号。
func canCreateBoundAccount(c *gin.Context) bool {
idt := currentIdentity(c)
return idt == pb.Identity_Admin || idt == pb.Identity_Manager
}
// minAccountPasswordLength 自助改密时新密码的最短长度(改别人密码的 resetpwd 是超管操作,不设限)。
const minAccountPasswordLength = 6
// accountPasswordLength 自动生成的初始密码长度(用短码字符集,去掉了易混的 0/O/I/1,
// 便于在邮件里照抄)。12 位 × 32 字符集 ≈ 60 bit 熵,够用。
const accountPasswordLength = 12
// genAccountPassword 生成初始密码。
func genAccountPassword() (string, error) {
return comm.GenerateShortCode(accountPasswordLength)
}
// uniqueAccountName 由建议名派生一个未被占用的用户名:base 没被占就用 base,
// 否则挂 2 位随机短码后缀重试。用于「品牌商/渠道商账号名默认由 id 推导」的场景——
// 正常不会撞(id 本身唯一),撞了多半是历史账号没删干净,加后缀比直接报错更顺手。
func (this *serverComp) uniqueAccountName(base string) (string, error) {
if this.checkNewAccountName(base) == "" {
return base, nil
}
for i := 0; i < 5; i++ {
suffix, err := comm.GenerateShortCode(2)
if err != nil {
return "", err
}
name := base + "-" + suffix
if this.checkNewAccountName(name) == "" {
return name, nil
}
}
return "", fmt.Errorf("用户名 %s 已存在且多次加后缀仍冲突,请手工指定", base)
}
// mailAccountCredentials 把新开账号的登录凭据发到对方联系邮箱。
// 发信失败只回传原因、不回滚账号:账号已经建好了,页面上还会把明文密码显示一次,
// 管理员可以另行转达,没必要因为一封邮件把整单撤了。
func (this *serverComp) mailAccountCredentials(to []string, roleLabel, ownerName, username, password string) error {
if len(to) == 0 {
return fmt.Errorf("对方未填联系邮箱")
}
// 用【系统配置 → 邮件服务】的配置热重载发信客户端;库里没配则沿用 yaml 启动配置。
if err := this.applySysEmail(); err != nil {
this.module.Warnf("mailAccountCredentials: 系统配置邮件未就绪,沿用启动配置: %v", err)
}
site := this.options.SiteName
if site == "" {
site = "运营控制台"
}
subject := fmt.Sprintf("【%s】%s账号开通通知", site, roleLabel)
body := fmt.Sprintf(`
您好,%s:
平台已为您开通「%s」后台账号,登录凭据如下:
请妥善保管本邮件;如需修改密码或停用账号,请联系平台管理员。
本邮件由 %s 自动发送,请勿直接回复。
`, ownerName, roleLabel, username, password, site)
return email.SendMail(subject, body, to...)
}
// checkNewAccountName 预校验待新建账号的用户名(只读,不落库);返回错误说明(空串=通过)。
func (this *serverComp) checkNewAccountName(username string) string {
if username == "" {
return "用户名必填"
}
if username == this.options.AdminAccount {
return "用户名与引导超管冲突,请换一个"
}
if exist, _ := this.module.model.getAccountByName(username); exist != nil && exist.Id != 0 {
return "用户名已存在"
}
return ""
}
// checkNewAccount 预校验待新建账号的用户名/密码(只读,不落库);返回错误说明(空串=通过)。
// 「同时创建账号」的入口先调它再去建品牌商/渠道商:用户名撞了就整单不做,
// 不至于建完品牌商才发现账号建不了,留下一个没账号的半成品。
func (this *serverComp) checkNewAccount(username, password string) string {
if username == "" || password == "" {
return "用户名和密码必填"
}
return this.checkNewAccountName(username)
}
// createBoundAccount 落库一个绑定品牌商/渠道商的账号(密码 bcrypt 哈希,建出来即启用)。
// 菜单权限留空:前端按角色回退到 DEFAULT_BRAND_ACCESS / DEFAULT_DEALER_ACCESS 默认集。
// 调用前须先过 checkNewAccount 与 validChannelBinding。
func (this *serverComp) createBoundAccount(username, password, remark string, idt pb.Identity, brandid uint32, channelid string) (*Account, error) {
hash, err := hashPassword(password)
if err != nil {
return nil, err
}
now := time.Now().Unix()
model := &Account{
Username: username,
Password: hash,
Identity: idt,
Enabled: true,
Remark: remark,
Brandid: brandid,
Channelid: channelid,
Createtime: now,
Updatetime: now,
}
if err := this.module.model.addAccount(model); err != nil {
return nil, err
}
return model, nil
}
// boundAccountResult 「新增品牌商/渠道商时顺手开账号」的结果。
// Password 是明文,只在创建那一次的响应里回显(库里只有 bcrypt 哈希,事后谁也查不回来)——
// 邮件可能发不出去(对方没填邮箱、SMTP 挂了),页面上必须能看到一次,否则账号就成了死号。
type boundAccountResult struct {
Username string
Password string
MailTo string // 已发往的邮箱(逗号分隔)
MailErr string // 发送失败原因
}
// credentialMails 从联系邮箱 CSV 里挑出「真正该收凭据的人」:去掉平台自己的默认邮箱
// (defaultBrandContactMail 是为了收授权码清单强行塞进品牌商邮箱列表的,不是品牌商的人)。
func credentialMails(mails string) []string {
out := make([]string, 0, 2)
for _, m := range splitMails(mails) {
if strings.EqualFold(m, defaultBrandContactMail) {
continue
}
out = append(out, m)
}
return out
}
// openBoundAccount 给刚建好的品牌商/渠道商开一个绑定账号,并把登录凭据邮件发过去。
// - username 留空 → 用 defaultName(由 id 推导,如 Brand-A001 / Channel-K7QM3ZTX),撞名自动加后缀;
// - password 留空 → 随机生成 12 位可读密码。
//
// 建号失败返回 error(调用方据此报错);邮件失败只记在结果里,不影响账号已建成的事实。
func (this *serverComp) openBoundAccount(idt pb.Identity, defaultName, username, password, remark string,
brandid uint32, channelid, roleLabel, ownerName, mails string) (*boundAccountResult, error) {
name := strings.TrimSpace(username)
if name == "" {
var err error
if name, err = this.uniqueAccountName(defaultName); err != nil {
return nil, err
}
}
pwd := password
if pwd == "" {
var err error
if pwd, err = genAccountPassword(); err != nil {
return nil, err
}
}
if _, err := this.createBoundAccount(name, pwd, remark, idt, brandid, channelid); err != nil {
return nil, err
}
res := &boundAccountResult{Username: name, Password: pwd}
to := credentialMails(mails)
if err := this.mailAccountCredentials(to, roleLabel, ownerName, name, pwd); err != nil {
res.MailErr = err.Error()
this.module.Warnf("openBoundAccount: 账号 %s 已创建,凭据邮件发送失败: %v", name, err)
} else {
res.MailTo = strings.Join(to, ",")
}
return res, nil
}
// accountsList 账号列表(Password 字段 json:"-",不外泄)。
func (this *serverComp) accountsList(c *gin.Context) {
list, err := this.module.model.listAccounts()
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, list)
}
// accountsAdd 新建账号。校验用户名唯一(且不撞引导超管)、角色合法、密码非空。
func (this *serverComp) accountsAdd(c *gin.Context) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
Identity pb.Identity `json:"identity"`
Remark string `json:"remark"`
Enabled bool `json:"enabled"`
Access string `json:"access"` // 可访问页面 CSV
Apps string `json:"apps"` // 绑定应用名称 CSV
Regions string `json:"regions"` // 绑定区域代码 CSV
Products string `json:"products"` // 绑定产品 id CSV
// 渠道分成体系的归属绑定:品牌商账号只填 brandid;渠道商账号两者都填。
Brandid uint32 `json:"brandid"`
Channelid string `json:"channelid"`
}
if err := c.ShouldBindJSON(&req); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
if msg := this.checkNewAccount(req.Username, req.Password); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
if !validIdentity(req.Identity) {
writeErr(c, pb.ErrorCode_ReqParameterError, identityHint)
return
}
if msg := validChannelBinding(req.Identity, req.Brandid, req.Channelid); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
hash, err := hashPassword(req.Password)
if err != nil {
writeErr(c, pb.ErrorCode_SystemError, err.Error())
return
}
now := time.Now().Unix()
model := &Account{
Username: req.Username,
Password: hash,
Identity: req.Identity,
Enabled: req.Enabled,
Remark: req.Remark,
Access: req.Access,
Apps: req.Apps,
Regions: req.Regions,
Products: req.Products,
Brandid: req.Brandid,
Channelid: req.Channelid,
Createtime: now,
Updatetime: now,
}
if err := this.module.model.addAccount(model); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, model)
}
// accountsUpdate 改账号(角色/启用/备注;不在此改密码,改密走 resetpwd)。
func (this *serverComp) accountsUpdate(c *gin.Context) {
var req struct {
Id uint32 `json:"id"`
Identity pb.Identity `json:"identity"`
Remark string `json:"remark"`
Enabled bool `json:"enabled"`
Access string `json:"access"`
Apps string `json:"apps"`
Regions string `json:"regions"`
Products string `json:"products"`
Brandid uint32 `json:"brandid"`
Channelid string `json:"channelid"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Id == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填")
return
}
if !validIdentity(req.Identity) {
writeErr(c, pb.ErrorCode_ReqParameterError, identityHint)
return
}
if msg := validChannelBinding(req.Identity, req.Brandid, req.Channelid); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
model, err := this.module.model.getAccount(req.Id)
if err != nil || model.Id == 0 {
writeErr(c, pb.ErrorCode_DBError, "账号不存在")
return
}
model.Identity = req.Identity
model.Remark = req.Remark
model.Enabled = req.Enabled
model.Access = req.Access
model.Apps = req.Apps
model.Regions = req.Regions
model.Products = req.Products
model.Brandid = req.Brandid
model.Channelid = req.Channelid
model.Updatetime = time.Now().Unix()
if err := this.module.model.saveAccount(model); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, model)
}
// accountsChangePwd 当前登录账号自助改密:校验旧密码后换成新密码。任意角色都能改自己的,
// 不需要找超管;改别人的仍走 accountsResetPwd(超管专属)。
//
// yaml 引导超管(account_id=0)改不了:它的密码在部署环境文件 CONSOLE_ADMIN_PASSWORD 里,
// 不入库——这是有意的,忘了密码还能上服务器翻/改配置再重启,是最后一把备用钥匙,
// 不该被后台改掉(否则一旦库里的值丢了就彻底进不去)。
func (this *serverComp) accountsChangePwd(c *gin.Context) {
var req struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
if req.OldPassword == "" || req.NewPassword == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "原密码和新密码必填")
return
}
if len(req.NewPassword) < minAccountPasswordLength {
writeErr(c, pb.ErrorCode_ReqParameterError, fmt.Sprintf("新密码至少 %d 位", minAccountPasswordLength))
return
}
if req.NewPassword == req.OldPassword {
writeErr(c, pb.ErrorCode_ReqParameterError, "新密码不能与原密码相同")
return
}
id := currentAccountId(c)
if id == 0 {
writeErr(c, pb.ErrorCode_InsufficientPermissions,
"引导超管的密码在部署环境文件(CONSOLE_ADMIN_PASSWORD)里,请到服务器修改后重启 console 生效")
return
}
model, err := this.module.model.getAccount(id)
if err != nil || model.Id == 0 {
writeErr(c, pb.ErrorCode_DBError, "账号不存在")
return
}
if !checkPassword(model.Password, req.OldPassword) {
writeErr(c, pb.ErrorCode_ReqParameterError, "原密码不正确")
return
}
hash, err := hashPassword(req.NewPassword)
if err != nil {
writeErr(c, pb.ErrorCode_SystemError, err.Error())
return
}
model.Password = hash
model.Updatetime = time.Now().Unix()
if err := this.module.model.saveAccount(model); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, gin.H{"id": id})
}
// accountsResetPwd 重置某账号密码(bcrypt 重新哈希)。
func (this *serverComp) accountsResetPwd(c *gin.Context) {
var req struct {
Id uint32 `json:"id"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Id == 0 || req.Password == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "id 和新密码必填")
return
}
model, err := this.module.model.getAccount(req.Id)
if err != nil || model.Id == 0 {
writeErr(c, pb.ErrorCode_DBError, "账号不存在")
return
}
hash, err := hashPassword(req.Password)
if err != nil {
writeErr(c, pb.ErrorCode_SystemError, err.Error())
return
}
model.Password = hash
model.Updatetime = time.Now().Unix()
if err := this.module.model.saveAccount(model); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, gin.H{"id": req.Id})
}
// accountsDel 删除账号。
func (this *serverComp) accountsDel(c *gin.Context) {
var req struct {
Id uint32 `json:"id"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Id == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填")
return
}
if err := this.module.model.delAccount(req.Id); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, gin.H{"id": req.Id})
}