package console /* 账号数据作用域(后端强制隔离)。 登录时把账号绑定(apps/products/regions,CSV)写进 JWT,tokenValid 解析后放进 gin.Context。 每个数据接口用 scopeOf(c) 取出作用域,对查询/结果强制按绑定收敛——无论前端传什么、是否传 X-App-Id, 代理(3)/运营(4) 都只能拿到自己绑定范围内的数据;超管(1)/管理员(2) 不受限。 语义(与前端既有行为一致):某维度绑定为空 = 该维度不限制;非空 = 仅限列表内。 */ import ( "strconv" "strings" "yunyan/pb" "github.com/gin-gonic/gin" ) type acctScope struct { unlimited bool // 超管/管理员:不受任何限制 apps []string // 绑定应用名(空=应用维度不限) products []uint32 // 绑定产品 id(空=产品维度不限) regions []string // 绑定区域代码(空=区域维度不限) // 渠道分成体系的归属绑定(P3 起): // 品牌商账号(代理,3):brandId 非 0,可见该品牌商及其名下全部渠道商的数据; // 渠道商账号(5) :brandId + channelId 都有,只可见自己这一个渠道商的数据。 // 与 apps/products 一样是「非空才限制」,故超管/管理员天然不受影响。 brandId uint32 channelId string } func ctxStr(c *gin.Context, k string) string { if v, ok := c.Get(k); ok { if s, ok := v.(string); ok { return s } } return "" } func splitCSV(s string) []string { out := make([]string, 0) for _, p := range strings.Split(s, ",") { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out } func splitCSVU32(s string) []uint32 { out := make([]uint32, 0) for _, p := range splitCSV(s) { if n, err := strconv.ParseUint(p, 10, 32); err == nil { out = append(out, uint32(n)) } } return out } func containsStr(list []string, v string) bool { for _, x := range list { if x == v { return true } } return false } // scopeOf 从已鉴权的请求上下文解析当前账号的数据作用域。 func scopeOf(c *gin.Context) acctScope { switch currentIdentity(c) { case pb.Identity_Admin, pb.Identity_Manager: return acctScope{unlimited: true} // 超管/管理员不受工厂/产品/应用限制 default: return acctScope{ apps: splitCSV(ctxStr(c, "apps")), products: splitCSVU32(ctxStr(c, "products")), regions: splitCSV(ctxStr(c, "regions")), brandId: ctxU32(c, "brandid"), channelId: ctxStr(c, "channelid"), } } } // ctxU32 从上下文取 uint32(tokenValid 写入)。 func ctxU32(c *gin.Context, k string) uint32 { if v, ok := c.Get(k); ok { if n, ok := v.(uint32); ok { return n } } return 0 } // allowBrand 该账号是否可见某品牌商的数据。品牌商/渠道商账号只可见自己绑定的品牌商。 func (s acctScope) allowBrand(id uint32) bool { return s.unlimited || s.brandId == 0 || s.brandId == id } // allowChannel 该账号是否可见某渠道商的数据。渠道商账号只可见自己那一个。 // 品牌商账号不在此限制(它看的是名下全部渠道商,由 allowBrand + 列表过滤把关)。 func (s acctScope) allowChannel(id string) bool { return s.unlimited || s.channelId == "" || s.channelId == id } // filterChannels 把渠道商列表收敛到账号可见范围:渠道商账号只剩自己,品牌商账号只剩 // 本品牌商名下的 + 全局渠道商。 // // ⚠️ brandid==0 是「全局渠道商」(后台新增渠道商已不再选品牌商),对所有账号可见。 // 少了这个判断,品牌商账号会看到一个空列表——allowBrand(0) 在 brandId 非 0 时恒为 false。 func (s acctScope) filterChannels(in []*pb.DBChannel) []*pb.DBChannel { if s.unlimited || (s.brandId == 0 && s.channelId == "") { return in } out := make([]*pb.DBChannel, 0, len(in)) for _, ch := range in { if (ch.Brandid == 0 || s.allowBrand(ch.Brandid)) && s.allowChannel(ch.Id) { out = append(out, ch) } } return out } func (s acctScope) allowApp(name string) bool { return s.unlimited || len(s.apps) == 0 || containsStr(s.apps, name) } func (s acctScope) allowProduct(id uint32) bool { if s.unlimited || len(s.products) == 0 { return true } for _, p := range s.products { if p == id { return true } } return false } // filterProducts 把产品列表收敛到绑定范围内。 func (s acctScope) filterProducts(in []*pb.DBProduct) []*pb.DBProduct { if s.unlimited || len(s.products) == 0 { return in } out := make([]*pb.DBProduct, 0, len(in)) for _, p := range in { if s.allowProduct(p.Id) { out = append(out, p) } } return out } // statsClause 返回账号作用域的统计过滤子句(与前端下钻条件 AND,强制收敛、防越权)。 // // 口径:按"最具体的绑定"收敛——有产品绑定就**只按产品**,否则按应用。这样对绑定产品的代理, // 「全部产品」(不传产品) 与「选该产品」(传 product_id) 落到同一过滤 product_id IN(绑定),结果一致。 // 注意:登录/用户/订单等"应用级"指标记在 product_id=0,按产品收敛后对代理显示 0(它们不挂产品); // apps 绑定改为只用于"用户查询"页(X-App-Id 校验),不参与仪表盘统计。 // 区域再 AND 上(含未归属 region=”,让产品级空区域行能通过)。超管/管理员或完全无绑定 → 返回空。 func (s acctScope) statsClause() (string, []interface{}) { if s.unlimited { return "", nil } conds := make([]string, 0, 2) args := make([]interface{}, 0, 2) // 渠道商账号:直接锁死到自己那一个渠道商,比产品/应用维度都具体,优先于它们。 // (品牌商账号的收敛在调用方按「本品牌商名下渠道商集合」注入,见 statsChannelClause。) if s.channelId != "" { conds = append(conds, "channel_id = ?") args = append(args, s.channelId) } else if len(s.products) > 0 { conds = append(conds, "product_id IN ?") args = append(args, s.products) } else if len(s.apps) > 0 { conds = append(conds, "app_id IN ?") args = append(args, s.apps) } if len(s.regions) > 0 { conds = append(conds, "(region IN ? OR region = '')") args = append(args, s.regions) } return strings.Join(conds, " AND "), args }