package console import ( "strings" "time" "yunyan/pb" "github.com/gin-gonic/gin" ) // ============================ 渠道商域 (channel) ============================ // // 渠道商挂在品牌商下:一个品牌商可绑定多个渠道商,id 为 8 位可读短码(主键)。 // 渠道商是生产授权码、统计与月结算的共同维度——生产时选定的渠道商随批次落进每条授权码, // 用户绑定该设备后其后续数据即归属该渠道商。 // // 新增走审批:品牌商账号自助添加的渠道商 status=0(待审核),须超管/管理员通过后 // (status=1 启用) 才能用于生产。超管/管理员自己新增的直接启用。 // // 数据在 console 主库(supabase),与品牌商/产品/授权码同库,故复用设备域的 consoleDeviceConn()。 // 渠道商状态取值(与 DBChannel.status 及前端一致)。 const ( channelStatusPending int32 = 0 // 待审核 channelStatusEnabled int32 = 1 // 启用 channelStatusDisabled int32 = 2 // 停用 channelStatusRejected int32 = 3 // 驳回 ) // channelShareRateMax 渠道分成比例上限(万分比,10000 = 100%)。 // 语义为「从品牌商那份里再切」,故本身不会超过 100%。 // 现在校验的是【生产批次】上的比例(production_batch.sharerate),渠道商表里那列已停止维护。 const channelShareRateMax int32 = 10000 // canAuditChannel 是否有渠道商审批权(超管/管理员)。 func canAuditChannel(c *gin.Context) bool { idt := currentIdentity(c) return idt == pb.Identity_Admin || idt == pb.Identity_Manager } // getChannels 列出渠道商。前端传 brandid 过滤;status 不传(0) 与「待审核」冲突, // 故约定 status<0 为不限,前端默认传 -1。 func (this *serverComp) getChannels(c *gin.Context, sys *appConn) { var req pb.ApiGetChannelsReq _ = c.ShouldBindJSON(&req) // 品牌商/渠道商账号:品牌商过滤强制成自己的绑定,再对结果按可见范围收敛(双保险)。 models, err := dvChannels(sys, effectiveBrandId(c, req.Brandid), req.Status, req.Keyword) if err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } writeOK(c, &pb.ApiGetChannelsResp{Channels: scopeOf(c).filterChannels(models)}) } func (this *serverComp) getChannel(c *gin.Context, sys *appConn) { var req pb.ApiGetChannelReq _ = c.ShouldBindJSON(&req) if strings.TrimSpace(req.Id) == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填") return } model, err := dvChannel(sys, strings.TrimSpace(req.Id)) if err != nil { writeErr(c, pb.ErrorCode_DBError, "渠道商不存在: "+req.Id) return } if !channelVisible(c, model) { writeErr(c, pb.ErrorCode_InsufficientPermissions, "无权访问该渠道商") return } writeOK(c, &pb.ApiGetChannelResp{Channel: model}) } // addOfficialChannel 给刚建好的品牌商配一条「官方渠道」:品牌自营的那条销售线。 // 与手工新增的渠道商不同——它随品牌商自动生成、直接启用(无需审批)、不配渠道账号 // (品牌自己的账号就管着它),分成比例固定 0:品牌那份提成本来就全归品牌自己, // 再从自家渠道切一刀没有意义。name 留空则用「<品牌名>官方渠道」。 func addOfficialChannel(sys *appConn, brand *pb.DBBrand, name, operator string) (*pb.DBChannel, error) { now := time.Now().Unix() if name = strings.TrimSpace(name); name == "" { name = brand.Brand + "官方渠道" } m := &pb.DBChannel{ Id: genChannelId(sys), Brandid: brand.Id, Name: name, Contactmails: brand.Contactmails, Remark: "品牌官方渠道(随品牌商自动创建,代表品牌自营销售)", Sharerate: 0, Status: channelStatusEnabled, Official: true, Auditaccount: operator, Audittime: now, Createtime: now, Updatetime: now, } if err := dvAddChannel(sys, m); err != nil { return nil, err } return m, nil } // addChannel 新增渠道商。id 一律服务端生成短码,前端传入的 id 被忽略。 // 无审批权的账号(品牌商)提交后为待审核,且申请人记在 applyaccount 上。 // 勾选 create_account 时一并建一个绑定该渠道商的渠道商账号(identity=5,仅超管可建)。 func (this *serverComp) addChannel(c *gin.Context, sys *appConn) { var req pb.ApiAddChannelReq _ = c.ShouldBindJSON(&req) if req.Channel == nil { writeErr(c, pb.ErrorCode_ReqParameterError, "channel 必填") return } m := req.Channel m.Name = strings.TrimSpace(m.Name) if m.Name == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "渠道商名称必填") return } // 品牌商已不是渠道商的必填项(2026-09-02):渠道商是全局实体,归属由生产批次决定。 // 品牌商账号自助新增时仍默认落到自己名下,这样它自己看得见;其余情况一律 0=全局。 if m.Brandid == 0 { m.Brandid = scopeOf(c).brandId } // 渠道商账号无权新建。 if currentIdentity(c) == pb.Identity_Dealer { writeErr(c, pb.ErrorCode_InsufficientPermissions, "渠道商账号无权新增渠道商") return } if m.Brandid != 0 { if !scopeOf(c).allowBrand(m.Brandid) { writeErr(c, pb.ErrorCode_InsufficientPermissions, "无权在该品牌商下新增渠道商") return } if _, err := dvBrand(sys, m.Brandid); err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, "品牌商不存在") return } } // 同时开渠道账号:超管/管理员。用户名/密码留空由服务端生成,显式填了才提前查重。 if req.CreateAccount { if !canCreateBoundAccount(c) { writeErr(c, pb.ErrorCode_InsufficientPermissions, "仅超管/管理员可在新增渠道商时同时创建渠道账号") return } if u := strings.TrimSpace(req.AccountUsername); u != "" { if msg := this.checkNewAccountName(u); msg != "" { writeErr(c, pb.ErrorCode_ReqParameterError, "渠道账号"+msg) return } } } // 分成比例已改为按生产批次设置(production_batch.sharerate),渠道商表单里不再有这一项。 // 新建一律 0,别从请求里读——留着会让「渠道商级比例」和「批次级比例」两个口径同时存在。 m.Sharerate = 0 now := time.Now().Unix() m.Id = genChannelId(sys) m.Official = false // 官方渠道只能由 addOfficialChannel 随品牌商生成,前端传什么都不作数 m.Createtime = now m.Updatetime = now // 审批权决定落库状态:超管/管理员直接启用,其余一律待审核。 if canAuditChannel(c) { m.Status = channelStatusEnabled m.Auditaccount = ctxStr(c, "username") m.Audittime = now } else { m.Status = channelStatusPending m.Applyaccount = ctxStr(c, "username") m.Auditaccount = "" m.Audittime = 0 } m.Auditremark = "" if err := dvAddChannel(sys, m); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } resp := &pb.ApiAddChannelResp{Channel: m} if req.CreateAccount { // 渠道商账号须同时绑品牌商与渠道商(validChannelBinding 的约定),渠道刚落库,绑定必然自洽。 acc, err := this.openBoundAccount(pb.Identity_Dealer, "Channel-"+m.Id, req.AccountUsername, req.AccountPassword, req.AccountRemark, m.Brandid, m.Id, "渠道商", m.Name, m.Contactmails) if err != nil { writeErr(c, pb.ErrorCode_DBError, "渠道商已创建,但渠道账号创建失败: "+err.Error()) return } resp.Account, resp.AccountPassword = acc.Username, acc.Password resp.MailTo, resp.MailError = acc.MailTo, acc.MailErr } writeOK(c, resp) } // updateChannel 更新渠道商资料。id/brandid 不可改(改归属等于换了一个渠道商, // 已生产的授权码会与新归属矛盾);状态只能走审批接口,避免绕过审批自行启用。 func (this *serverComp) updateChannel(c *gin.Context, sys *appConn) { var req pb.ApiUpdateChannelReq _ = c.ShouldBindJSON(&req) if req.Channel == nil || strings.TrimSpace(req.Channel.Id) == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "channel id 必填") return } old, err := dvChannel(sys, strings.TrimSpace(req.Channel.Id)) if err != nil { writeErr(c, pb.ErrorCode_DBError, "渠道商不存在: "+req.Channel.Id) return } if !channelVisible(c, old) { writeErr(c, pb.ErrorCode_InsufficientPermissions, "无权修改该渠道商") return } if name := strings.TrimSpace(req.Channel.Name); name != "" { old.Name = name } old.Contact = req.Channel.Contact old.Contactmails = req.Channel.Contactmails old.Remark = req.Channel.Remark // Sharerate 刻意不写:比例已移到生产批次上,这里既不读也不清零—— // 清零会把存量渠道商已有的比例抹掉,而月结算目前还在读它。 old.Updatetime = time.Now().Unix() if err = dvSaveChannel(sys, old); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } writeOK(c, &pb.ApiUpdateChannelResp{Channel: old}) } // delChannel 删除渠道商。已产生生产批次的渠道商禁止删除——历史授权码/统计/结算都按 id 归属, // 删掉会让这些数据指向不存在的渠道;要停用请走审批接口置 status=2。 func (this *serverComp) delChannel(c *gin.Context, sys *appConn) { var req pb.ApiDelChannelReq _ = c.ShouldBindJSON(&req) id := strings.TrimSpace(req.Id) if id == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填") return } ch, err := dvChannel(sys, id) if err != nil { writeErr(c, pb.ErrorCode_DBError, "渠道商不存在: "+id) return } if !channelVisible(c, ch) { writeErr(c, pb.ErrorCode_InsufficientPermissions, "无权删除该渠道商") return } // 官方渠道是品牌商的一部分(品牌自营销售线),随品牌商生老病死,不单独删。 if ch.Official { writeErr(c, pb.ErrorCode_ReqParameterError, "品牌官方渠道不能删除(它随品牌商自动创建);如需停用请走审批") return } n, err := dvCountBatchesByChannel(sys, id) if err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } if n > 0 { writeErr(c, pb.ErrorCode_ReqParameterError, "该渠道商已有生产批次,不能删除,请改为停用") return } if err := dvDelChannel(sys, id); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } writeOK(c, &pb.ApiDelChannelResp{Id: id}) } // auditChannel 审批/启停渠道商:通过(1) / 停用(2) / 驳回(3)。仅超管、管理员。 func (this *serverComp) auditChannel(c *gin.Context, sys *appConn) { var req pb.ApiAuditChannelReq _ = c.ShouldBindJSON(&req) if !canAuditChannel(c) { writeErr(c, pb.ErrorCode_InsufficientPermissions, "仅超管/管理员可审批渠道商") return } id := strings.TrimSpace(req.Id) if id == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填") return } switch req.Status { case channelStatusEnabled, channelStatusDisabled, channelStatusRejected: default: writeErr(c, pb.ErrorCode_ReqParameterError, "status 仅支持 1(通过启用) / 2(停用) / 3(驳回)") return } old, err := dvChannel(sys, id) if err != nil { writeErr(c, pb.ErrorCode_DBError, "渠道商不存在: "+id) return } old.Status = req.Status old.Auditaccount = ctxStr(c, "username") old.Auditremark = req.Auditremark old.Audittime = time.Now().Unix() old.Updatetime = old.Audittime if err = dvSaveChannel(sys, old); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } writeOK(c, &pb.ApiAuditChannelResp{Channel: old}) } // resolveProductionChannelModel 生产 MAC/授权码时解析并校验渠道商:必选、须为启用状态。 // // ⚠️ 不校验「渠道商归属该品牌商」:生产表单已不再选品牌商(2026-09-02),品牌归属反过来 // 由渠道商推导(见 resolveProductionBrandId)。渠道商现在是全局实体、brandid 常年为 0, // 继续按品牌比对只会让所有渠道商都用不了。 // 返回校验通过的渠道商;校验失败时已写好错误响应,调用方直接 return。 func resolveProductionChannelModel(c *gin.Context, sys *appConn, channelid string) (*pb.DBChannel, bool) { id := strings.TrimSpace(channelid) if id == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "渠道商必选") return nil, false } ch, err := dvChannel(sys, id) if err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, "渠道商不存在: "+id) return nil, false } if ch.Status != channelStatusEnabled { writeErr(c, pb.ErrorCode_ReqParameterError, "渠道商未启用(待审核/停用/驳回),不能用于生产: "+id) return nil, false } return ch, true } // resolveProductionBrandId 决定这一批货记在哪个品牌商名下。 // // 生产表单已经不选品牌商了(2026-09-02),所以品牌归属改成推导出来的: // 1. 品牌商账号自助生产 → 强制记在自己名下。放在最前面是因为它同时是权限约束, // 不能让前端传值或渠道商的归属把它绕开。 // 2. 选中的是某品牌的「官方渠道」→ 继承该渠道商的 brandid,月结算的品牌维度不至于丢。 // 3. 其余(全局渠道商)→ 0 = 无品牌归属,结算时那份提成全归平台。 // // ⚠️ 请求里的 brandid 一律忽略。留着它「前端传就用」会造出第四种口径, // 而前端已经没有地方能填这个值了。 func resolveProductionBrandId(c *gin.Context, ch *pb.DBChannel) uint32 { if s := scopeOf(c); s.brandId != 0 { return s.brandId } if ch != nil { return ch.Brandid } return 0 } // channelVisible 该渠道商是否在当前账号的可见范围内: // 品牌商账号看本品牌商名下全部渠道商,渠道商账号只看自己那一个,超管/管理员不受限。 func channelVisible(c *gin.Context, ch *pb.DBChannel) bool { s := scopeOf(c) // brandid==0 = 全局渠道商,对所有账号可见(渠道商账号仍只看自己那一个)。 return (ch.Brandid == 0 || s.allowBrand(ch.Brandid)) && s.allowChannel(ch.Id) }