├── doc ├── concepts.png ├── paths_version.yaml ├── concepts.md ├── paths_module.yaml ├── paths_product.yaml ├── paths_user.yaml ├── paths_group.yaml └── paths_label.yaml ├── src ├── dto │ └── group.go ├── util │ ├── common.go │ ├── util.go │ ├── dig.go │ ├── hid_test.go │ ├── hid.go │ └── config.go ├── model │ ├── healthz.go │ ├── statistic.go │ ├── module.go │ ├── setting_rule.go │ ├── product.go │ └── label_rule.go ├── api │ ├── healthz.go │ ├── healthz_test.go │ ├── label_v2.go │ ├── setting_v2.go │ ├── module.go │ ├── app.go │ ├── product.go │ ├── app_test.go │ ├── label_v2_test.go │ ├── user.go │ ├── group.go │ ├── setting_v2_test.go │ ├── label.go │ └── module_test.go ├── tpl │ ├── label_user.go │ ├── setting_user.go │ ├── label_group.go │ ├── setting_group.go │ ├── module.go │ ├── user.go │ ├── common_test.go │ ├── label_rule.go │ ├── setting_rule.go │ ├── pagination.go │ ├── group.go │ ├── product.go │ ├── label.go │ └── common.go ├── schema │ ├── lock.go │ ├── group_label.go │ ├── user_label.go │ ├── user_group.go │ ├── user_setting.go │ ├── group_setting.go │ ├── product.go │ ├── module.go │ ├── label_rule.go │ ├── group.go │ ├── label.go │ ├── setting_rule.go │ ├── statistic.go │ ├── setting.go │ ├── common.go │ └── user.go ├── bll │ ├── common.go │ ├── module.go │ ├── product.go │ ├── group.go │ └── user_test.go ├── service │ ├── hider.go │ └── mysql.go ├── logging │ └── logger.go ├── middleware │ └── auth.go └── conf │ └── config.go ├── sql ├── update_20201022.sql ├── update_20200424.sql └── update_20200314.sql ├── Dockerfile ├── docker-compose.yaml ├── .gitignore ├── README.md ├── go.mod ├── config ├── test.yml ├── test_on_github.yml └── default.yml ├── main.go ├── LICENSE ├── .github └── workflows │ ├── test.yml │ └── release.yml ├── Makefile ├── CHANGELOG.md └── CODE_OF_CONDUCT.md /doc/concepts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teambition/urbs-setting/master/doc/concepts.png -------------------------------------------------------------------------------- /src/dto/group.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | const ( 4 | // GroupOrgKind ... 5 | GroupOrgKind = "organization" 6 | ) 7 | -------------------------------------------------------------------------------- /sql/update_20201022.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `urbs_group` DROP index `uk_group_uid`; 2 | ALTER TABLE `urbs_group` ADD unique `uk_group_uid_kind` (`uid`,`kind`); -------------------------------------------------------------------------------- /src/util/common.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | // StringSliceHas ... 4 | func StringSliceHas(sl []string, v string) bool { 5 | for _, s := range sl { 6 | if v == s { 7 | return true 8 | } 9 | } 10 | return false 11 | } 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | 3 | WORKDIR /opt/bin 4 | 5 | ENV CONFIG_FILE_PATH=/etc/urbs-setting/config.yml 6 | COPY config/default.yml /etc/urbs-setting/config.yml 7 | 8 | COPY ./dist/urbs-setting . 9 | 10 | ENTRYPOINT ["./urbs-setting"] 11 | -------------------------------------------------------------------------------- /src/model/healthz.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | // Healthz ... 9 | type Healthz struct { 10 | *Model 11 | } 12 | 13 | // DBStats ... 14 | func (m *Healthz) DBStats(ctx context.Context) sql.DBStats { 15 | return m.SQL.DBStats() 16 | } 17 | -------------------------------------------------------------------------------- /src/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | // Go ... 9 | func Go(du time.Duration, fn func(context.Context)) { 10 | ctx, cancel := context.WithTimeout(context.Background(), du) 11 | 12 | go func() { 13 | fn(ctx) 14 | cancel() 15 | }() 16 | } 17 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | mysql: 4 | image: mysql:5.7 5 | command: --default-authentication-plugin=mysql_native_password 6 | ports: 7 | - "3306:3306" 8 | volumes: 9 | - ./sql:/docker-entrypoint-initdb.d 10 | environment: 11 | MYSQL_USER: root 12 | MYSQL_ROOT_PASSWORD: password -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | dist 18 | debug 19 | .vscode -------------------------------------------------------------------------------- /doc/paths_version.yaml: -------------------------------------------------------------------------------- 1 | /version: 2 | get: 3 | tags: 4 | - Version 5 | summary: 获取版本信息 6 | responses: 7 | '200': 8 | $ref: '#/components/responses/Version' 9 | 10 | /healthz: 11 | get: 12 | tags: 13 | - Version 14 | summary: 服务健康检查接口 15 | responses: 16 | '200': 17 | $ref: '#/components/responses/Healthz' -------------------------------------------------------------------------------- /src/util/dig.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | // util 模块不要引入其它内部模块 4 | import "go.uber.org/dig" 5 | 6 | var globalDig = dig.New() 7 | 8 | // DigInvoke ... 9 | func DigInvoke(function interface{}, opts ...dig.InvokeOption) error { 10 | return globalDig.Invoke(function, opts...) 11 | } 12 | 13 | // DigProvide ... 14 | func DigProvide(constructor interface{}, opts ...dig.ProvideOption) error { 15 | return globalDig.Provide(constructor, opts...) 16 | } 17 | -------------------------------------------------------------------------------- /src/api/healthz.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/teambition/gear" 5 | "github.com/teambition/urbs-setting/src/bll" 6 | ) 7 | 8 | // Healthz .. 9 | type Healthz struct { 10 | blls *bll.Blls 11 | } 12 | 13 | // Get .. 14 | func (a *Healthz) Get(ctx *gear.Context) error { 15 | stats := a.blls.Models.Healthz.DBStats(ctx) 16 | return ctx.OkJSON(map[string]interface{}{ 17 | "dbConnect": stats.OpenConnections > 0, 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/tpl/label_user.go: -------------------------------------------------------------------------------- 1 | package tpl 2 | 3 | import "time" 4 | 5 | // LabelUserInfo ... 6 | type LabelUserInfo struct { 7 | ID int64 `json:"-" db:"id"` 8 | LabelHID string `json:"labelHID"` 9 | AssignedAt time.Time `json:"assignedAt" db:"assigned_at"` 10 | Release int64 `json:"release" db:"rls"` 11 | User string `json:"user" db:"uid"` 12 | } 13 | 14 | // LabelUsersInfoRes ... 15 | type LabelUsersInfoRes struct { 16 | SuccessResponseType 17 | Result []LabelUserInfo `json:"result"` 18 | } 19 | -------------------------------------------------------------------------------- /src/schema/lock.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import "time" 4 | 5 | // schema 模块不要引入官方库以外的其它模块或内部模块 6 | 7 | // TableLock is a table name in db. 8 | const TableLock = "urbs_lock" 9 | 10 | // Lock 详见 ./sql/schema.sql table `urbs_lock` 11 | // 内部锁 12 | type Lock struct { 13 | ID int64 `db:"id" goqu:"skipinsert"` 14 | Name string `db:"name"` // varchar(255) 锁键,表内唯一 15 | ExpireAt time.Time `db:"expire_at"` 16 | } 17 | 18 | // TableName retuns table name 19 | func (Lock) TableName() string { 20 | return "urbs_lock" 21 | } 22 | -------------------------------------------------------------------------------- /src/schema/group_label.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | // schema 模块不要引入官方库以外的其它模块或内部模块 4 | import ( 5 | "time" 6 | ) 7 | 8 | // TableGroupLabel is a table name in db. 9 | const TableGroupLabel = "group_label" 10 | 11 | // GroupLabel 详见 ./sql/schema.sql table `group_label` 12 | // 记录群组被设置的环境标签,将作用于群组所有成员 13 | type GroupLabel struct { 14 | ID int64 `db:"id" goqu:"skipinsert"` 15 | CreatedAt time.Time `db:"created_at" goqu:"skipinsert"` 16 | GroupID int64 `db:"group_id"` // 群组内部 ID 17 | LabelID int64 `db:"label_id"` // 环境标签内部 ID 18 | Release int64 `db:"rls"` // 标签被设置计数批次 19 | } 20 | -------------------------------------------------------------------------------- /src/schema/user_label.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | // schema 模块不要引入官方库以外的其它模块或内部模块 4 | import ( 5 | "time" 6 | ) 7 | 8 | // TableUserLabel is a table name in db. 9 | const TableUserLabel = "user_label" 10 | 11 | // UserLabel 详见 ./sql/schema.sql table `user_label` 12 | // 记录用户被分配的环境标签,不同客户端不同大版本可能有不同的环境标签 13 | type UserLabel struct { 14 | ID int64 `db:"id" goqu:"skipinsert"` 15 | CreatedAt time.Time `db:"created_at" goqu:"skipinsert"` 16 | UserID int64 `db:"user_id"` // 用户内部 ID 17 | LabelID int64 `db:"label_id"` // 环境标签内部 ID 18 | Release int64 `db:"rls"` // 标签被设置计数批次 19 | } 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # urbs-setting 2 | 3 | > Urbs 灰度平台灰度策略服务 4 | 5 | [![License](http://img.shields.io/badge/license-mit-blue.svg?style=flat-square)](https://raw.githubusercontent.com/teambition/urbs-setting/master/LICENSE) 6 | 7 | ## Features 8 | 9 | + 支持多套独立的灰度策略配置 10 | + 支持后端灰度策略配置,后端服务灰度能力可以配合 https://github.com/teambition/traefik 网关 11 | + 支持客户端功能模块的 A/B 测试策略配置 12 | + 支持基于群组对于一批人进行灰度策略配置 13 | + 可以很方便的对接业务方的用户系统和组织架构 14 | 15 | ## Concepts 16 | 17 | [Concepts](https://github.com/teambition/urbs-setting/blob/master/doc/concepts.md) 18 | 19 | ## Documentation 20 | 21 | [API 文档](https://github.com/teambition/urbs-setting/blob/master/doc/openapi.md) 22 | -------------------------------------------------------------------------------- /src/api/healthz_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/DavidCai1993/request" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestHealthzAPIs(t *testing.T) { 12 | tt, cleanup := SetUpTestTools() 13 | defer cleanup() 14 | 15 | t.Run(`"GET /healthz" should work`, func(t *testing.T) { 16 | assert := assert.New(t) 17 | 18 | res, err := request.Get(fmt.Sprintf("%s/healthz", tt.Host)).End() 19 | assert.Nil(err) 20 | assert.Equal(200, res.StatusCode) 21 | 22 | json := map[string]interface{}{} 23 | res.JSON(&json) 24 | assert.True(json["dbConnect"].(bool)) 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /src/model/statistic.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/doug-martin/goqu/v9" 7 | "github.com/teambition/urbs-setting/src/schema" 8 | ) 9 | 10 | // Statistic ... 11 | type Statistic struct { 12 | *Model 13 | } 14 | 15 | // FindByKey ... 16 | func (m *Statistic) FindByKey(ctx context.Context, key schema.StatisticKey) (*schema.Statistic, error) { 17 | statistic := &schema.Statistic{} 18 | ok, err := m.findOneByCols(ctx, schema.TableStatistic, goqu.Ex{"name": key}, "", statistic) 19 | if err != nil { 20 | return nil, err 21 | } 22 | if !ok { 23 | return nil, nil 24 | } 25 | return statistic, nil 26 | } 27 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/teambition/urbs-setting 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/DavidCai1993/request v0.0.0-20171115020405-aad722fa9b76 7 | github.com/doug-martin/goqu/v9 v9.10.0 8 | github.com/go-sql-driver/mysql v1.5.0 9 | github.com/open-trust/ot-go-lib v0.3.0 10 | github.com/opentracing/opentracing-go v1.2.0 // indirect 11 | github.com/stretchr/testify v1.5.1 12 | github.com/teambition/gear v1.21.6 13 | github.com/teambition/gear-auth v1.7.0 14 | github.com/teambition/gear-tracing v1.1.1 15 | go.uber.org/dig v1.10.0 16 | golang.org/x/tools v0.0.0-20200917221617-d56e4e40bc9d // indirect 17 | gopkg.in/yaml.v2 v2.3.0 18 | ) 19 | -------------------------------------------------------------------------------- /src/tpl/setting_user.go: -------------------------------------------------------------------------------- 1 | package tpl 2 | 3 | import "time" 4 | 5 | // SettingUserInfo ... 6 | type SettingUserInfo struct { 7 | ID int64 `json:"-" db:"id"` 8 | SettingHID string `json:"settingHID"` 9 | AssignedAt time.Time `json:"assignedAt" db:"assigned_at"` 10 | Release int64 `json:"release" db:"rls"` 11 | User string `json:"user" db:"uid"` 12 | Value string `json:"value" db:"value"` 13 | LastValue string `json:"lastValue" db:"last_value"` 14 | } 15 | 16 | // SettingUsersInfoRes ... 17 | type SettingUsersInfoRes struct { 18 | SuccessResponseType 19 | Result []SettingUserInfo `json:"result"` 20 | } 21 | -------------------------------------------------------------------------------- /src/api/label_v2.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/teambition/gear" 5 | "github.com/teambition/urbs-setting/src/tpl" 6 | ) 7 | 8 | // AssignV2 .. 9 | func (a *Label) AssignV2(ctx *gear.Context) error { 10 | req := tpl.ProductLabelURL{} 11 | if err := ctx.ParseURL(&req); err != nil { 12 | return err 13 | } 14 | 15 | body := tpl.UsersGroupsBodyV2{} 16 | if err := ctx.ParseBody(&body); err != nil { 17 | return err 18 | } 19 | 20 | res, err := a.blls.Label.Assign(ctx, req.Product, req.Label, body.Users, body.Groups) 21 | if err != nil { 22 | return err 23 | } 24 | return ctx.OkJSON(tpl.LabelReleaseInfoRes{Result: *res}) 25 | } 26 | -------------------------------------------------------------------------------- /config/test.yml: -------------------------------------------------------------------------------- 1 | addr: ":3000" 2 | logger: 3 | level: error 4 | mysql: 5 | host: localhost:3306 6 | user: root 7 | password: password 8 | database: urbs 9 | parameters: loc=UTC&readTimeout=10s&writeTimeout=10s&timeout=10s&multiStatements=true 10 | max_idle_conns: 8 11 | max_open_conns: 64 12 | channels: 13 | - stable 14 | - beta 15 | - canary 16 | - dev 17 | clients: 18 | - web 19 | - ios 20 | - android 21 | - windows 22 | - macos 23 | cache_label_expire: 10s # 用于测试 24 | auth_keys: [] 25 | hid_key: q7FltzZWfvGIrdEdHYY # 一旦设定,尽量不要改变,否则派生出去的 HID 无法识别 26 | open_trust: 27 | otid: "" 28 | private_keys: [] 29 | domain_public_keys: [] 30 | -------------------------------------------------------------------------------- /config/test_on_github.yml: -------------------------------------------------------------------------------- 1 | addr: ":3000" 2 | logger: 3 | level: error 4 | mysql: 5 | host: localhost:3306 6 | user: root 7 | password: root 8 | database: urbs 9 | parameters: loc=UTC&readTimeout=10s&writeTimeout=10s&timeout=10s&multiStatements=false 10 | max_idle_conns: 8 11 | max_open_conns: 64 12 | channels: 13 | - stable 14 | - beta 15 | - canary 16 | - dev 17 | clients: 18 | - web 19 | - ios 20 | - android 21 | - windows 22 | - macos 23 | cache_label_expire: 10s # 用于测试 24 | auth_keys: [] 25 | hid_key: q7FltzZWfvGIrdEdHYY # 一旦设定,尽量不要改变,否则派生出去的 HID 无法识别 26 | open_trust: 27 | otid: "" 28 | private_keys: [] 29 | domain_public_keys: [] 30 | -------------------------------------------------------------------------------- /src/schema/user_group.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | // schema 模块不要引入官方库以外的其它模块或内部模块 4 | import ( 5 | "time" 6 | ) 7 | 8 | // TableUserGroup is a table name in db. 9 | const TableUserGroup = "user_group" 10 | 11 | // UserGroup 详见 ./sql/schema.sql table `user_group` 12 | // 记录用户从属的群组,用户可以归属到多个群组 13 | // 用户能从所归属的群组继承环境标签和功能项配置,也就是基于群组进行灰度 14 | type UserGroup struct { 15 | ID int64 `db:"id" goqu:"skipinsert"` 16 | CreatedAt time.Time `db:"created_at" goqu:"skipinsert"` 17 | SyncAt int64 `db:"sync_at"` // 归属关系同步时间戳,1970 以来的秒数,应该与 group.sync_at 相等 18 | UserID int64 `db:"user_id"` // 用户内部 ID 19 | GroupID int64 `db:"group_id"` // 群组内部 ID 20 | } 21 | -------------------------------------------------------------------------------- /src/tpl/label_group.go: -------------------------------------------------------------------------------- 1 | package tpl 2 | 3 | import "time" 4 | 5 | // LabelGroupInfo ... 6 | type LabelGroupInfo struct { 7 | ID int64 `json:"-" db:"id"` 8 | LabelHID string `json:"labelHID"` 9 | AssignedAt time.Time `json:"assignedAt" db:"assigned_at"` 10 | Release int64 `json:"release" db:"rls"` 11 | Group string `json:"group" db:"uid"` 12 | Kind string `json:"kind" db:"kind"` 13 | Desc string `json:"desc" db:"description"` 14 | Status int64 `json:"status" db:"status"` 15 | } 16 | 17 | // LabelGroupsInfoRes ... 18 | type LabelGroupsInfoRes struct { 19 | SuccessResponseType 20 | Result []LabelGroupInfo `json:"result"` 21 | } 22 | -------------------------------------------------------------------------------- /src/api/setting_v2.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/teambition/gear" 5 | "github.com/teambition/urbs-setting/src/tpl" 6 | ) 7 | 8 | // AssignV2 .. 9 | func (a *Setting) AssignV2(ctx *gear.Context) error { 10 | req := tpl.ProductModuleSettingURL{} 11 | if err := ctx.ParseURL(&req); err != nil { 12 | return err 13 | } 14 | 15 | body := tpl.UsersGroupsBodyV2{} 16 | if err := ctx.ParseBody(&body); err != nil { 17 | return err 18 | } 19 | 20 | res, err := a.blls.Setting.Assign(ctx, req.Product, req.Module, req.Setting, body.Value, body.Users, body.Groups) 21 | if err != nil { 22 | return err 23 | } 24 | return ctx.OkJSON(tpl.SettingReleaseInfoRes{Result: *res}) 25 | } 26 | -------------------------------------------------------------------------------- /src/bll/common.go: -------------------------------------------------------------------------------- 1 | package bll 2 | 3 | import ( 4 | "github.com/teambition/urbs-setting/src/model" 5 | "github.com/teambition/urbs-setting/src/util" 6 | ) 7 | 8 | func init() { 9 | util.DigProvide(NewBlls) 10 | } 11 | 12 | // Blls ... 13 | type Blls struct { 14 | User *User 15 | Group *Group 16 | Product *Product 17 | Label *Label 18 | Module *Module 19 | Setting *Setting 20 | Models *model.Models 21 | } 22 | 23 | // NewBlls ... 24 | func NewBlls(models *model.Models) *Blls { 25 | return &Blls{ 26 | User: &User{ms: models}, 27 | Group: &Group{ms: models}, 28 | Product: &Product{ms: models}, 29 | Label: &Label{ms: models}, 30 | Module: &Module{ms: models}, 31 | Setting: &Setting{ms: models}, 32 | Models: models, 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/schema/user_setting.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | // schema 模块不要引入官方库以外的其它模块或内部模块 4 | import ( 5 | "time" 6 | ) 7 | 8 | // TableUserSetting is a table name in db. 9 | const TableUserSetting = "user_setting" 10 | 11 | // UserSetting 详见 ./sql/schema.sql table `user_setting` 12 | // 记录用户对某功能模块配置项值 13 | type UserSetting struct { 14 | ID int64 `db:"id" goqu:"skipinsert"` 15 | CreatedAt time.Time `db:"created_at" goqu:"skipinsert"` 16 | UpdatedAt time.Time `db:"updated_at" goqu:"skipinsert"` 17 | UserID int64 `db:"user_id"` // 用户内部 ID 18 | SettingID int64 `db:"setting_id"` // 配置项内部 ID 19 | Value string `db:"value"` // varchar(255),配置值 20 | LastValue string `db:"last_value"` // varchar(255),上一次配置值 21 | Release int64 `db:"rls"` // 配置项被设置计数批次 22 | } 23 | -------------------------------------------------------------------------------- /src/schema/group_setting.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | // schema 模块不要引入官方库以外的其它模块或内部模块 4 | import ( 5 | "time" 6 | ) 7 | 8 | // TableGroupSetting is a table name in db. 9 | const TableGroupSetting = "group_setting" 10 | 11 | // GroupSetting 详见 ./sql/schema.sql table `group_setting` 12 | // 记录群组对某功能模块配置项值,将作用于群组所有成员 13 | type GroupSetting struct { 14 | ID int64 `db:"id" goqu:"skipinsert"` 15 | CreatedAt time.Time `db:"created_at" goqu:"skipinsert"` 16 | UpdatedAt time.Time `db:"updated_at" goqu:"skipinsert"` 17 | GroupID int64 `db:"group_id"` // 群组内部 ID 18 | SettingID int64 `db:"setting_id"` // 配置项内部 ID 19 | Value string `db:"value"` // varchar(255),配置值 20 | LastValue string `db:"last_value"` // varchar(255),上一次配置值 21 | Release int64 `db:"rls"` // 配置项被设置计数批次 22 | } 23 | -------------------------------------------------------------------------------- /src/tpl/setting_group.go: -------------------------------------------------------------------------------- 1 | package tpl 2 | 3 | import "time" 4 | 5 | // SettingGroupInfo ... 6 | type SettingGroupInfo struct { 7 | ID int64 `json:"-" db:"id"` 8 | SettingHID string `json:"settingHID"` 9 | AssignedAt time.Time `json:"assignedAt" db:"assigned_at"` 10 | Release int64 `json:"release" db:"rls"` 11 | Group string `json:"group" db:"uid"` 12 | Kind string `json:"kind" db:"kind"` 13 | Desc string `json:"desc" db:"description"` 14 | Status int64 `json:"status" db:"status"` 15 | Value string `json:"value" db:"value"` 16 | LastValue string `json:"lastValue" db:"last_value"` 17 | } 18 | 19 | // SettingGroupsInfoRes ... 20 | type SettingGroupsInfoRes struct { 21 | SuccessResponseType 22 | Result []SettingGroupInfo `json:"result"` 23 | } 24 | -------------------------------------------------------------------------------- /doc/concepts.md: -------------------------------------------------------------------------------- 1 | # Urbs-Setting 名词概念 2 | 3 | ![名词关系](./concepts.png) 4 | 5 | ## user 用户 6 | 灰度系统的用户,仅以 uid 标记,uid 必须系统内唯一,实体来自于外部系统,如 Teambition 的 User。 7 | 8 | ## group 群组 9 | 灰度系统的群组,仅以 uid 和 kind 标记,uid 必须系统内唯一,实体来自于外部系统,如 Teambition 的 Organization、Project 等。 10 | 群组可以添加成员,可以被指派标签,可以被指派功能配置项。 11 | 被指派的标签和被指派功能配置项都会被群组成员继承。 12 | 13 | ## product 产品 14 | 灰度系统的产品,仅以 name 标记,name 必须系统内唯一,主要用于灰度配置隔离。比如一个灰度系统中可以包含 teambition、thoughts 等不同产品,每个产品有自己的灰度配置策略。 15 | 16 | ## module 功能模块 17 | 灰度系统产品下的功能模块,仅以 name 标记,name 在同一个产品下必须唯一。 18 | 19 | ## setting 配置项 20 | **配置项一般用于客户端功能模块的 A/B 测试(或功能降级),也可用于服务端功能模块的 A/B 测试(或功能降级)** 21 | 灰度系统产品下的功能模块配置项,又称 **功能开关**,仅以 name 标记,指派给用户或群组时应该设置配置值 value,name 在同一个功能模块下必须唯一。 22 | 客户端读取指定 uid 用户在指定 product 下的功能模块配置项列表,根据其值决定对应功能模块配置是否开启或开启不同状态。 23 | 24 | ## label 环境标签 25 | **环境标签用于后端服务的灰度。** 26 | 灰度系统的环境标签,仅以 name 标记,name 在同一个产品下。 27 | 后端网关会根据当前用户的环境标签,决定请求进入不同环境标签的服务。 28 | -------------------------------------------------------------------------------- /src/schema/product.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | // schema 模块不要引入官方库以外的其它模块或内部模块 4 | import ( 5 | "time" 6 | ) 7 | 8 | // TableProduct is a table name in db. 9 | const TableProduct = "urbs_product" 10 | 11 | // Product 详见 ./sql/schema.sql table `urbs_product` 12 | // 产品线 13 | type Product struct { 14 | ID int64 `db:"id" json:"-" goqu:"skipinsert"` 15 | CreatedAt time.Time `db:"created_at" json:"createdAt" goqu:"skipinsert"` 16 | UpdatedAt time.Time `db:"updated_at" json:"updatedAt" goqu:"skipinsert"` 17 | DeletedAt *time.Time `db:"deleted_at" json:"deletedAt"` // 删除时间,用于灰度管理 18 | OfflineAt *time.Time `db:"offline_at" json:"offlineAt"` // 下线时间,用于灰度管理 19 | Name string `db:"name" json:"name"` // varchar(63) 产品线名称,表内唯一 20 | Desc string `db:"description" json:"desc"` // varchar(1022) 产品线描述 21 | Status int64 `db:"status" json:"status"` // -1 下线弃用,未使用 22 | } 23 | 24 | // TableName retuns table name 25 | func (Product) TableName() string { 26 | return "urbs_product" 27 | } 28 | -------------------------------------------------------------------------------- /src/schema/module.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | // schema 模块不要引入官方库以外的其它模块或内部模块 4 | import ( 5 | "time" 6 | ) 7 | 8 | // TableModule is a table name in db. 9 | const TableModule = "urbs_module" 10 | 11 | // Module 详见 ./sql/schema.sql table `urbs_module` 12 | // 产品线的功能模块 13 | type Module struct { 14 | ID int64 `db:"id" json:"-" goqu:"skipinsert"` 15 | CreatedAt time.Time `db:"created_at" json:"createdAt" goqu:"skipinsert"` 16 | UpdatedAt time.Time `db:"updated_at" json:"updatedAt" goqu:"skipinsert"` 17 | OfflineAt *time.Time `db:"offline_at" json:"offlineAt"` // 计划下线时间,用于灰度管理 18 | ProductID int64 `db:"product_id"` // 所从属的产品线 ID 19 | Name string `db:"name" json:"name"` // varchar(63) 功能模块名称,产品线内唯一 20 | Desc string `db:"description" json:"desc"` // varchar(1022) 功能模块描述 21 | Status int64 `db:"status" json:"status"` // -1 下线弃用,有效配置项计数(被动异步计算,非精确值) 22 | } 23 | 24 | // TableName retuns table name 25 | func (Module) TableName() string { 26 | return "urbs_module" 27 | } 28 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/teambition/urbs-setting/src/api" 10 | "github.com/teambition/urbs-setting/src/conf" 11 | "github.com/teambition/urbs-setting/src/logging" 12 | ) 13 | 14 | var help = flag.Bool("help", false, "show help info") 15 | var version = flag.Bool("version", false, "show version info") 16 | 17 | func main() { 18 | flag.Parse() 19 | if *help || *version { 20 | data, _ := json.Marshal(api.GetVersion()) 21 | fmt.Println(string(data)) 22 | os.Exit(0) 23 | } 24 | 25 | if len(conf.Config.SrvAddr) == 0 { 26 | conf.Config.SrvAddr = ":8081" 27 | } 28 | 29 | app := api.NewApp() 30 | ctx := conf.Config.GlobalCtx 31 | host := "http://" + conf.Config.SrvAddr 32 | if conf.Config.CertFile != "" && conf.Config.KeyFile != "" { 33 | host = "https://" + conf.Config.SrvAddr 34 | } 35 | logging.Infof("Urbs-Setting start on %s", host) 36 | logging.Errf("Urbs-Setting closed %v", app.ListenWithContext( 37 | ctx, conf.Config.SrvAddr, conf.Config.CertFile, conf.Config.KeyFile)) 38 | } 39 | -------------------------------------------------------------------------------- /src/tpl/module.go: -------------------------------------------------------------------------------- 1 | package tpl 2 | 3 | import ( 4 | "github.com/teambition/gear" 5 | "github.com/teambition/urbs-setting/src/schema" 6 | ) 7 | 8 | // ModuleUpdateBody ... 9 | type ModuleUpdateBody struct { 10 | Desc *string `json:"desc"` 11 | } 12 | 13 | // Validate 实现 gear.BodyTemplate。 14 | func (t *ModuleUpdateBody) Validate() error { 15 | if t.Desc == nil { 16 | return gear.ErrBadRequest.WithMsgf("desc required") 17 | } 18 | 19 | if len(*t.Desc) > 1022 { 20 | return gear.ErrBadRequest.WithMsgf("desc too long: %d", len(*t.Desc)) 21 | } 22 | return nil 23 | } 24 | 25 | // ToMap ... 26 | func (t *ModuleUpdateBody) ToMap() map[string]interface{} { 27 | changed := make(map[string]interface{}) 28 | if t.Desc != nil { 29 | changed["description"] = *t.Desc 30 | } 31 | return changed 32 | } 33 | 34 | // ModuleRes ... 35 | type ModuleRes struct { 36 | SuccessResponseType 37 | Result schema.Module `json:"result"` // 空数组也保留 38 | } 39 | 40 | // ModulesRes ... 41 | type ModulesRes struct { 42 | SuccessResponseType 43 | Result []schema.Module `json:"result"` // 空数组也保留 44 | } 45 | -------------------------------------------------------------------------------- /src/schema/label_rule.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | // schema 模块不要引入官方库以外的其它模块或内部模块 4 | import ( 5 | "time" 6 | ) 7 | 8 | // TableLabelRule is a table name in db. 9 | const TableLabelRule = "label_rule" 10 | 11 | // LabelRule 详见 ./sql/schema.sql table `label_rule` 12 | // 环境标签发布规则 13 | type LabelRule struct { 14 | ID int64 `db:"id" goqu:"skipinsert"` 15 | CreatedAt time.Time `db:"created_at" goqu:"skipinsert"` 16 | UpdatedAt time.Time `db:"updated_at" goqu:"skipinsert"` 17 | ProductID int64 `db:"product_id"` // 所从属的产品线 ID,与环境标签的产品线一致 18 | LabelID int64 `db:"label_id"` // 规则所指向的环境标签 ID 19 | Kind string `db:"kind"` // 规则类型 20 | Rule string `db:"rule"` // varchar(1022),规则值,JSON string,对于 percent 类,其格式为 {"value": percent} 21 | Release int64 `db:"rls"` // 标签发布(被设置)计数批次 22 | } 23 | 24 | // TableName retuns table name 25 | func (LabelRule) TableName() string { 26 | return "label_rule" 27 | } 28 | 29 | // ToPercent retuns table name 30 | func (l LabelRule) ToPercent() int { 31 | return ToPercentRule(l.Kind, l.Rule).Rule.Value 32 | } 33 | -------------------------------------------------------------------------------- /src/schema/group.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | // schema 模块不要引入官方库以外的其它模块或内部模块 4 | import ( 5 | "time" 6 | ) 7 | 8 | // TableGroup is a table name in db. 9 | const TableGroup = "urbs_group" 10 | 11 | // Group 详见 ./sql/schema.sql table `urbs_group` 12 | // 用户群组 13 | type Group struct { 14 | ID int64 `db:"id" json:"-" goqu:"skipinsert"` 15 | CreatedAt time.Time `db:"created_at" json:"createdAt" goqu:"skipinsert"` 16 | UpdatedAt time.Time `db:"updated_at" json:"updatedAt" goqu:"skipinsert"` 17 | SyncAt int64 `db:"sync_at" json:"syncAt"` // 群组成员同步时间点,1970 以来的秒数 18 | UID string `db:"uid" json:"uid"` // varchar(63),群组外部ID,表内唯一, 如 Teambition organization id 19 | Kind string `db:"kind" json:"kind"` // varchar(63),群组外部ID,表内唯一, 如 Teambition organization id 20 | Desc string `db:"description" json:"desc"` // varchar(1022),群组描述 21 | Status int64 `db:"status" json:"status" db:"status"` // 成员计数(被动异步计算,非精确值) 22 | } 23 | 24 | // TableName retuns table name 25 | func (Group) TableName() string { 26 | return "urbs_group" 27 | } 28 | -------------------------------------------------------------------------------- /src/schema/label.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | // schema 模块不要引入官方库以外的其它模块或内部模块 4 | import ( 5 | "time" 6 | ) 7 | 8 | // TableLabel is a table name in db. 9 | const TableLabel = "urbs_label" 10 | 11 | // Label 详见 ./sql/schema.sql table `urbs_label` 12 | // 环境标签 13 | type Label struct { 14 | ID int64 `db:"id" goqu:"skipinsert"` 15 | CreatedAt time.Time `db:"created_at" goqu:"skipinsert"` 16 | UpdatedAt time.Time `db:"updated_at" goqu:"skipinsert"` 17 | OfflineAt *time.Time `db:"offline_at"` // 计划下线时间,用于灰度管理 18 | ProductID int64 `db:"product_id"` // 所从属的产品线 ID 19 | Name string `db:"name"` // varchar(63) 环境标签名称,产品线内唯一 20 | Desc string `db:"description"` // varchar(1022) 环境标签描述 21 | Channels string `db:"channels"` // varchar(255) 标签适用的版本通道,未配置表示都适用 22 | Clients string `db:"clients"` // varchar(255) 标签适用的客户端类型,未配置表示都适用 23 | Status int64 `db:"status"` // -1 下线弃用,使用用户计数(被动异步计算,非精确值) 24 | Release int64 `db:"rls"` // 标签发布(被设置)计数 25 | } 26 | 27 | // TableName retuns table name 28 | func (Label) TableName() string { 29 | return "urbs_label" 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Teambition 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/schema/setting_rule.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | // schema 模块不要引入官方库以外的其它模块或内部模块 4 | import ( 5 | "time" 6 | ) 7 | 8 | // TableSettingRule is a table name in db. 9 | const TableSettingRule = "setting_rule" 10 | 11 | // SettingRule 详见 ./sql/schema.sql table `setting_rule` 12 | // 环境标签发布规则 13 | type SettingRule struct { 14 | ID int64 `db:"id" goqu:"skipinsert"` 15 | CreatedAt time.Time `db:"created_at" goqu:"skipinsert"` 16 | UpdatedAt time.Time `db:"updated_at" goqu:"skipinsert"` 17 | ProductID int64 `db:"product_id"` // 所从属的产品线 ID,与环境标签的产品线一致 18 | SettingID int64 `db:"setting_id"` // 规则所指向的环境标签 ID 19 | Kind string `db:"kind"` // 规则类型 20 | Rule string `db:"rule"` // varchar(1022),规则值,JSON string,对于 percent 类,其格式为 {"value": percent} 21 | Value string `db:"value"` // varchar(255),配置值 22 | Release int64 `db:"rls"` // 标签发布(被设置)计数批次 23 | } 24 | 25 | // TableName retuns table name 26 | func (SettingRule) TableName() string { 27 | return "setting_rule" 28 | } 29 | 30 | // ToPercent retuns table name 31 | func (l SettingRule) ToPercent() int { 32 | return ToPercentRule(l.Kind, l.Rule).Rule.Value 33 | } 34 | -------------------------------------------------------------------------------- /src/service/hider.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/teambition/urbs-setting/src/conf" 5 | "github.com/teambition/urbs-setting/src/util" 6 | ) 7 | 8 | func init() { 9 | hIDer[""] = util.NewHID([]byte(conf.Config.HIDKey)) 10 | hIDer["label"] = util.NewHID([]byte("label" + conf.Config.HIDKey)) 11 | hIDer["setting"] = util.NewHID([]byte("setting" + conf.Config.HIDKey)) 12 | hIDer["label_rule"] = util.NewHID([]byte("label_rule" + conf.Config.HIDKey)) 13 | hIDer["setting_rule"] = util.NewHID([]byte("setting_rule" + conf.Config.HIDKey)) 14 | } 15 | 16 | // HIDer 全局 HID 转换器,目前仅支持 schema.Label, schema.setting 的 ID 转换。 17 | var hIDer = map[string]*util.HID{} 18 | 19 | // IDToHID 把 int64 的 ID 转换为 string HID,如果对象无效或者 ID (int64 > 0)不合法,则返回空字符串。 20 | func IDToHID(id int64, kind ...string) string { 21 | k := "" 22 | if len(kind) > 0 { 23 | k = kind[0] 24 | } 25 | if h, ok := hIDer[k]; ok { 26 | return h.ToHex(id) 27 | } 28 | return "" 29 | } 30 | 31 | // HIDToID 把 string 的 HID 转换为 int64 ID,如果 HID 字符串不合法或者对象不合法,则返回 0。 32 | func HIDToID(hid string, kind ...string) int64 { 33 | k := "" 34 | if len(kind) > 0 { 35 | k = kind[0] 36 | } 37 | if h, ok := hIDer[k]; ok { 38 | if id := h.ToInt64(hid); id > 0 { 39 | return id 40 | } 41 | } 42 | return 0 43 | } 44 | -------------------------------------------------------------------------------- /src/tpl/user.go: -------------------------------------------------------------------------------- 1 | package tpl 2 | 3 | import ( 4 | "github.com/teambition/gear" 5 | "github.com/teambition/urbs-setting/src/schema" 6 | ) 7 | 8 | // UsersBody ... 9 | type UsersBody struct { 10 | Users []string `json:"users"` 11 | } 12 | 13 | // Validate 实现 gear.BodyTemplate。 14 | func (t *UsersBody) Validate() error { 15 | if len(t.Users) == 0 { 16 | return gear.ErrBadRequest.WithMsg("users emtpy") 17 | } 18 | for _, uid := range t.Users { 19 | if !validIDReg.MatchString(uid) { 20 | return gear.ErrBadRequest.WithMsgf("invalid user: %s", uid) 21 | } 22 | } 23 | return nil 24 | } 25 | 26 | // UsersRes ... 27 | type UsersRes struct { 28 | SuccessResponseType 29 | Result []schema.User `json:"result"` 30 | } 31 | 32 | // UserRes ... 33 | type UserRes struct { 34 | SuccessResponseType 35 | Result schema.User `json:"result"` 36 | } 37 | 38 | // ApplyRulesBody ... 39 | type ApplyRulesBody struct { 40 | UsersBody 41 | Kind string `json:"kind"` 42 | } 43 | 44 | // Validate 实现 gear.BodyTemplate。 45 | func (t *ApplyRulesBody) Validate() error { 46 | if err := t.UsersBody.Validate(); err != nil { 47 | return err 48 | } 49 | if t.Kind == "" || !StringSliceHas(schema.RuleKinds, t.Kind) { 50 | return gear.ErrBadRequest.WithMsgf("invalid kind: %s", t.Kind) 51 | } 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /src/util/hid_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "math" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestHID(t *testing.T) { 11 | t.Run("HID should work", func(t *testing.T) { 12 | assert := assert.New(t) 13 | maxInt64 := int64(math.MaxInt64) 14 | 15 | hid1 := NewHID([]byte("abc")) 16 | hid2 := NewHID([]byte("123")) 17 | 18 | assert.Equal("", hid1.ToHex(-999)) 19 | assert.Equal("", hid1.ToHex(-1)) 20 | assert.Equal("", hid1.ToHex(0)) 21 | // assert.Equal("", hid1.ToHex(maxInt64+1)) 22 | 23 | s := hid1.ToHex(1) 24 | assert.Equal(int64(1), hid1.ToInt64(s)) 25 | assert.Equal(int64(0), hid2.ToInt64(s)) 26 | assert.Equal(24, len(s)) 27 | 28 | s = hid1.ToHex(999) 29 | assert.Equal(int64(999), hid1.ToInt64(s)) 30 | assert.Equal(int64(0), hid2.ToInt64(s)) 31 | assert.Equal(24, len(s)) 32 | 33 | s = hid1.ToHex(maxInt64) 34 | assert.Equal(maxInt64, hid1.ToInt64(s)) 35 | assert.Equal(int64(0), hid2.ToInt64(s)) 36 | assert.Equal(24, len(s)) 37 | 38 | assert.NotEqual(hid1.ToHex(1), hid2.ToHex(1)) 39 | assert.NotEqual(hid1.ToHex(maxInt64), hid2.ToHex(maxInt64)) 40 | }) 41 | } 42 | 43 | func BenchmarkHID(b *testing.B) { 44 | hid := NewHID([]byte("abc")) 45 | b.RunParallel(func(pb *testing.PB) { 46 | for pb.Next() { 47 | hid.ToHex(9999) 48 | } 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /src/schema/statistic.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import "time" 4 | 5 | // schema 模块不要引入官方库以外的其它模块或内部模块 6 | 7 | // TableStatistic is a table name in db. 8 | const TableStatistic = "urbs_statistic" 9 | 10 | // Statistic 详见 ./sql/schema.sql table `urbs_statistic` 11 | // 内部统计 12 | type Statistic struct { 13 | ID int64 `db:"id" goqu:"skipinsert"` 14 | CreatedAt time.Time `db:"created_at" goqu:"skipinsert"` 15 | UpdatedAt time.Time `db:"updated_at" goqu:"skipinsert"` 16 | Name string `db:"name"` // varchar(255) 锁键,表内唯一 17 | Status int64 `db:"status"` 18 | Value string `db:"value"` // varchar(8190) json value 19 | } 20 | 21 | // TableName retuns table name 22 | func (Statistic) TableName() string { 23 | return "urbs_statistic" 24 | } 25 | 26 | // StatisticKey ... 27 | type StatisticKey string 28 | 29 | // UsersTotalSize ... 30 | const ( 31 | UsersTotalSize StatisticKey = "UsersTotalSize" 32 | GroupsTotalSize StatisticKey = "GroupsTotalSize" 33 | ProductsTotalSize StatisticKey = "ProductsTotalSize" 34 | LabelsTotalSize StatisticKey = "LabelsTotalSize" 35 | ModulesTotalSize StatisticKey = "ModulesTotalSize" 36 | SettingsTotalSize StatisticKey = "SettingsTotalSize" 37 | LabelRulesTotalSize StatisticKey = "LabelRulesTotalSize" 38 | SettingRulesTotalSize StatisticKey = "SettingRulesTotalSize" 39 | ) 40 | -------------------------------------------------------------------------------- /src/schema/setting.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | // schema 模块不要引入官方库以外的其它模块或内部模块 4 | import ( 5 | "time" 6 | ) 7 | 8 | // TableSetting is a table name in db. 9 | const TableSetting = "urbs_setting" 10 | 11 | // Setting 详见 ./sql/schema.sql table `urbs_setting` 12 | // 功能模块的配置项 13 | type Setting struct { 14 | ID int64 `db:"id" goqu:"skipinsert"` 15 | CreatedAt time.Time `db:"created_at" goqu:"skipinsert"` 16 | UpdatedAt time.Time `db:"updated_at" goqu:"skipinsert"` 17 | OfflineAt *time.Time `db:"offline_at"` // 计划下线时间,用于灰度管理 18 | ModuleID int64 `db:"module_id"` // 配置项所从属的功能模块 ID 19 | Module string `db:"module" goqu:"skipinsert"` // 仅为查询方便追加字段,数据库中没有该字段 20 | Name string `db:"name"` // varchar(63) 配置项名称,功能模块内唯一 21 | Desc string `db:"description"` // varchar(1022) 配置项描述信息 22 | Channels string `db:"channels"` // varchar(255) 配置项适用的版本通道,未配置表示都适用 23 | Clients string `db:"clients"` // varchar(255) 配置项适用的客户端类型,未配置表示都适用 24 | Values string `db:"vals"` // varchar(1022) 配置项可选值集合 25 | Status int64 `db:"status"` // -1 下线弃用,使用用户计数(被动异步计算,非精确值) 26 | Release int64 `db:"rls"` // 配置项发布(被设置)计数 27 | } 28 | 29 | // TableName retuns table name 30 | func (Setting) TableName() string { 31 | return "urbs_setting" 32 | } 33 | -------------------------------------------------------------------------------- /config/default.yml: -------------------------------------------------------------------------------- 1 | addr: ":8080" 2 | cert_file: 3 | key_file: 4 | logger: 5 | level: debug 6 | mysql: 7 | host: localhost:3306 8 | user: root 9 | password: password 10 | database: urbs 11 | parameters: loc=UTC&readTimeout=10s&writeTimeout=10s&timeout=10s&multiStatements=false 12 | max_idle_conns: 8 13 | max_open_conns: 64 14 | mysql_read: 15 | host: localhost:3306 16 | user: root 17 | password: password 18 | database: urbs 19 | parameters: loc=UTC&readTimeout=10s&writeTimeout=10s&timeout=10s&multiStatements=false 20 | max_idle_conns: 8 21 | max_open_conns: 64 22 | channels: 23 | - stable 24 | - beta 25 | - dev 26 | clients: 27 | - web 28 | - ios 29 | - android 30 | - windows 31 | - macos 32 | cache_label_expire: 5m 33 | auth_keys: 34 | - kqGuLsiKT1J5ANFDKXUHc2lAYfdzWBnriL1iHgBbYQ 35 | hid_key: q7FltzZWfvGIrdEdHYY # 一旦设定,尽量不要改变,否则派生出去的 HID 无法识别 36 | open_trust: 37 | otid: "" 38 | legacy_otid: "" 39 | private_keys: [] 40 | domain_public_keys: 41 | - '{"kty":"RSA","alg":"PS256","e":"AQAB","kid":"4PblNZYSnOsy8sD6SHZPEl6DCqEerpgfi_sPxthHpWM","n":"0FjUWU9H6P9JTe3ZFOGxoVlYKFlzr98N44vIvjvvLVM1FU3MECJeTpztgnONZKelBO2YSY29v1mTl_PLWxVsn-gwkRczp1F5ogvt64dkPpaSdzpOLS1aKhqJSpVJp-D0lJWJ4ksEvyvM1hMNe9F3gbI6yyLigPhfF6qPdS2PxbFdilX4TmvrmViFnkVT31L4aXVuaEg9juLfxbIs-lnbvE9_L0a-zm-PfN-sLP3_SrPtUBLRH-cVgiMc43eXqU1H5AqJ0XzPHdrwzTRFiZuLsyaI2zj67D2x9Wwn8ze2OeP_B6th97XQfS_6zJ5BDs_VPoQi19F0Ts3dWnlXi2CrhQ"}' 42 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Urbs-Setting Test 2 | on: 3 | # Trigger the workflow on push or pull request, 4 | # but only for the master branch 5 | push: 6 | branches: 7 | - master 8 | - develop 9 | pull_request: 10 | branches: 11 | - master 12 | 13 | jobs: 14 | build: 15 | name: Testing 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Set up Go 19 | uses: actions/setup-go@v1 20 | with: 21 | go-version: 1.14.2 22 | id: go 23 | 24 | - name: Check out code into the Go module directory 25 | uses: actions/checkout@v2 26 | 27 | # use mysql in VM https://github.com/actions/virtual-environments/blob/master/images/linux/Ubuntu1804-README.md 28 | # https://github.com/actions/virtual-environments/issues/576 29 | - name: Try connect to MySQL and init datebase 30 | run: | 31 | export TZ=UTC 32 | sudo systemctl enable mysql.service 33 | sudo systemctl start mysql.service 34 | mysql -hlocalhost -uroot -proot < ./sql/schema.sql 35 | 36 | - name: Get dependencies 37 | run: | 38 | go get -v -t -d ./... 39 | 40 | - name: Lint 41 | run: | # temporary fix. See https://github.com/actions/setup-go/issues/14 42 | export PATH=$PATH:$(go env GOPATH)/bin 43 | go get -u golang.org/x/lint/golint 44 | make lint 45 | 46 | - name: Test 47 | run: | 48 | CONFIG_FILE_PATH=${PWD}/config/test_on_github.yml APP_ENV=test go test -p 1 -v ./... 49 | -------------------------------------------------------------------------------- /src/util/hid.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | // util 模块不要引入其它内部模块 4 | import ( 5 | "bytes" 6 | "crypto/hmac" 7 | "crypto/sha1" 8 | "encoding/base64" 9 | "encoding/binary" 10 | "math" 11 | ) 12 | 13 | const maxInt64u = uint64(math.MaxInt64) 14 | 15 | // HID 基于 HMAC 算法,将内部 int64 的 ID 与 base64 URL 字符串进行相互转换。API 接口不支持 int64 ID 参数。 16 | type HID struct { 17 | key []byte 18 | } 19 | 20 | // NewHID 根据给定的秘钥生成 HID 实例。 21 | func NewHID(key []byte) *HID { 22 | return &HID{key: key} 23 | } 24 | 25 | // ToHex 将内部 ID(大于0的 int64)转换成24位 base64 URL 字符串。 26 | // 如果输入值 <= 0,则返回空字符串。 27 | func (h *HID) ToHex(i int64) string { 28 | if i <= 0 { 29 | return "" 30 | } 31 | data := make([]byte, 8) 32 | binary.LittleEndian.PutUint64(data, uint64(i)) 33 | 34 | hs := hmac.New(sha1.New, h.key) 35 | hs.Write(data) 36 | sum := hs.Sum(nil) 37 | hs.Reset() 38 | 39 | copy(sum[:8], data) 40 | return base64.URLEncoding.EncodeToString(sum[0:18]) 41 | } 42 | 43 | // ToInt64 将合法的24位 base64 URL 字符串转换成内部 ID(大于0的 int64)。 44 | // 如果输入值不合法,则返回0。 45 | func (h *HID) ToInt64(s string) int64 { 46 | if s == "" { 47 | return 0 48 | } 49 | data, err := base64.URLEncoding.DecodeString(s) 50 | if len(data) != 18 || err != nil { 51 | return 0 52 | } 53 | x := binary.LittleEndian.Uint64(data[:8]) 54 | if x > maxInt64u { 55 | return 0 56 | } 57 | hs := hmac.New(sha1.New, h.key) 58 | hs.Write(data[:8]) 59 | sum := hs.Sum(nil) 60 | hs.Reset() 61 | if !bytes.Equal(data[8:18], sum[8:18]) { 62 | return 0 63 | } 64 | return int64(x) 65 | } 66 | -------------------------------------------------------------------------------- /src/tpl/common_test.go: -------------------------------------------------------------------------------- 1 | package tpl 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestStringToSlice(t *testing.T) { 10 | t.Run(`StringToSlice should work`, func(t *testing.T) { 11 | assert := assert.New(t) 12 | assert.Equal([]string{}, StringToSlice("")) 13 | 14 | assert.Equal([]string{"a"}, StringToSlice("a")) 15 | assert.Equal([]string{"a", "b"}, StringToSlice("a,b")) 16 | }) 17 | } 18 | 19 | func TestStringSliceHas(t *testing.T) { 20 | t.Run(`StringSliceHas should work`, func(t *testing.T) { 21 | assert := assert.New(t) 22 | assert.True(StringSliceHas([]string{"a", "b"}, "b")) 23 | assert.False(StringSliceHas([]string{"a", "bb"}, "b")) 24 | assert.False(StringSliceHas([]string{}, "b")) 25 | assert.False(StringSliceHas([]string{}, "")) 26 | assert.False(StringSliceHas([]string{"a"}, "")) 27 | }) 28 | } 29 | 30 | func TestSortStringsAndCheck(t *testing.T) { 31 | t.Run(`SortStringsAndCheck should work`, func(t *testing.T) { 32 | assert := assert.New(t) 33 | 34 | s := []string{} 35 | assert.True(SortStringsAndCheck(s)) 36 | 37 | s = []string{"a"} 38 | assert.True(SortStringsAndCheck(s)) 39 | 40 | s = []string{""} 41 | assert.False(SortStringsAndCheck(s)) 42 | 43 | s = []string{"b", "c", "a"} 44 | assert.True(SortStringsAndCheck(s)) 45 | assert.Equal([]string{"a", "b", "c"}, s) 46 | 47 | s = []string{"b", "c", "a", "c"} 48 | assert.False(SortStringsAndCheck(s)) 49 | 50 | s = []string{"b", "c", "a", ""} 51 | assert.False(SortStringsAndCheck(s)) 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /src/util/config.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | // util 模块不要引入其它内部模块 4 | import ( 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "sync" 12 | 13 | yaml "gopkg.in/yaml.v2" 14 | ) 15 | 16 | var once sync.Once 17 | 18 | // ReadConfig 指定配置文件并解析; 未指定配置文件则通过环境变量获取 19 | func ReadConfig(v interface{}, path ...string) { 20 | once.Do(func() { 21 | filePath, err := getConfigFilePath(path...) 22 | if err != nil { 23 | panic(err) 24 | } 25 | 26 | ext := filepath.Ext(filePath) 27 | 28 | data, err := ioutil.ReadFile(filePath) 29 | if err != nil { 30 | panic(err) 31 | } 32 | 33 | err = parseConfig(data, ext, v) 34 | if err != nil { 35 | panic(err) 36 | } 37 | }) 38 | } 39 | 40 | func getConfigFilePath(path ...string) (string, error) { 41 | // 优先使用的环境变量 42 | filePath := os.Getenv("CONFIG_FILE_PATH") 43 | 44 | // 或使用指定的路径 45 | if filePath == "" && len(path) > 0 { 46 | filePath = path[0] 47 | } 48 | 49 | if filePath == "" { 50 | return "", fmt.Errorf("config file not specified") 51 | } 52 | 53 | return filePath, nil 54 | } 55 | 56 | type unmarshaler func(data []byte, v interface{}) error 57 | 58 | func parseConfig(data []byte, ext string, v interface{}) error { 59 | ext = strings.TrimLeft(ext, ".") 60 | 61 | var unmarshal unmarshaler 62 | 63 | switch ext { 64 | case "json": 65 | unmarshal = json.Unmarshal 66 | case "yaml", "yml": 67 | unmarshal = yaml.Unmarshal 68 | default: 69 | return fmt.Errorf("not supported config ext: %s", ext) 70 | } 71 | 72 | return unmarshal(data, v) 73 | } 74 | -------------------------------------------------------------------------------- /src/tpl/label_rule.go: -------------------------------------------------------------------------------- 1 | package tpl 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/teambition/urbs-setting/src/schema" 7 | "github.com/teambition/urbs-setting/src/service" 8 | ) 9 | 10 | // LabelRuleBody ... 11 | type LabelRuleBody struct { 12 | schema.PercentRule 13 | } 14 | 15 | // LabelRuleInfo ... 16 | type LabelRuleInfo struct { 17 | ID int64 `json:"-"` 18 | HID string `json:"hid"` 19 | LabelHID string `json:"labelHID"` 20 | Kind string `json:"kind"` 21 | Rule interface{} `json:"rule"` 22 | Release int64 `json:"release"` 23 | CreatedAt time.Time `json:"createdAt"` 24 | UpdatedAt time.Time `json:"updatedAt"` 25 | } 26 | 27 | // LabelRuleInfoFrom ... 28 | func LabelRuleInfoFrom(labelRule schema.LabelRule) LabelRuleInfo { 29 | return LabelRuleInfo{ 30 | ID: labelRule.ID, 31 | HID: service.IDToHID(labelRule.ID, "label_rule"), 32 | LabelHID: service.IDToHID(labelRule.LabelID, "label"), 33 | Kind: labelRule.Kind, 34 | Rule: schema.ToRuleObject(labelRule.Kind, labelRule.Rule), 35 | Release: labelRule.Release, 36 | CreatedAt: labelRule.CreatedAt, 37 | UpdatedAt: labelRule.UpdatedAt, 38 | } 39 | } 40 | 41 | // LabelRulesInfoFrom ... 42 | func LabelRulesInfoFrom(labelRules []schema.LabelRule) []LabelRuleInfo { 43 | res := make([]LabelRuleInfo, len(labelRules)) 44 | for i, l := range labelRules { 45 | res[i] = LabelRuleInfoFrom(l) 46 | } 47 | return res 48 | } 49 | 50 | // LabelRulesInfoRes ... 51 | type LabelRulesInfoRes struct { 52 | SuccessResponseType 53 | Result []LabelRuleInfo `json:"result"` // 空数组也保留 54 | } 55 | 56 | // LabelRuleInfoRes ... 57 | type LabelRuleInfoRes struct { 58 | SuccessResponseType 59 | Result LabelRuleInfo `json:"result"` // 空数组也保留 60 | } 61 | -------------------------------------------------------------------------------- /src/schema/common.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/teambition/urbs-setting/src/util" 8 | ) 9 | 10 | const ( 11 | // RuleUserPercent ... 12 | RuleUserPercent = "userPercent" 13 | // RuleNewUserPercent ... 14 | RuleNewUserPercent = "newUserPercent" 15 | // RuleChildLabelUserPercent parent-child relationship label 16 | RuleChildLabelUserPercent = "childLabelUserPercent" 17 | ) 18 | 19 | var ( 20 | // RuleKinds ... 21 | RuleKinds = []string{RuleUserPercent, RuleNewUserPercent, RuleChildLabelUserPercent} 22 | ) 23 | 24 | // PercentRule ... 25 | type PercentRule struct { 26 | Kind string `json:"kind"` 27 | Rule struct { 28 | Value int `json:"value"` 29 | } `json:"rule"` 30 | } 31 | 32 | // Validate ... 33 | func (r *PercentRule) Validate() error { 34 | if r.Kind == "" || !util.StringSliceHas(RuleKinds, r.Kind) { 35 | return fmt.Errorf("invalid kind: %s", r.Kind) 36 | } 37 | if r.Rule.Value < 0 || r.Rule.Value > 100 { 38 | return fmt.Errorf("invalid percent rule value: %d", r.Rule.Value) 39 | } 40 | return nil 41 | } 42 | 43 | // ToRule ... 44 | func (r *PercentRule) ToRule() string { 45 | if b, err := json.Marshal(r.Rule); err == nil { 46 | return string(b) 47 | } 48 | return "" 49 | } 50 | 51 | // ToPercentRule ... 52 | func ToPercentRule(kind, rule string) *PercentRule { 53 | r := &PercentRule{Kind: kind} 54 | r.Rule.Value = -1 55 | if rule != "" { 56 | if err := json.Unmarshal([]byte(rule), &r.Rule); err != nil { 57 | r.Rule.Value = -1 58 | } 59 | 60 | if err := r.Validate(); err != nil { 61 | r.Rule.Value = -1 62 | } 63 | } 64 | 65 | return r 66 | } 67 | 68 | // ToRuleObject ... 69 | func ToRuleObject(kind, rule string) interface{} { 70 | return ToPercentRule(kind, rule).Rule 71 | } 72 | -------------------------------------------------------------------------------- /src/api/module.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/teambition/gear" 5 | "github.com/teambition/urbs-setting/src/bll" 6 | "github.com/teambition/urbs-setting/src/tpl" 7 | ) 8 | 9 | // Module .. 10 | type Module struct { 11 | blls *bll.Blls 12 | } 13 | 14 | // List .. 15 | func (a *Module) List(ctx *gear.Context) error { 16 | req := tpl.ProductPaginationURL{} 17 | if err := ctx.ParseURL(&req); err != nil { 18 | return err 19 | } 20 | res, err := a.blls.Module.List(ctx, req.Product, req.Pagination) 21 | if err != nil { 22 | return err 23 | } 24 | return ctx.OkJSON(res) 25 | } 26 | 27 | // Create .. 28 | func (a *Module) Create(ctx *gear.Context) error { 29 | req := tpl.ProductURL{} 30 | if err := ctx.ParseURL(&req); err != nil { 31 | return err 32 | } 33 | 34 | body := tpl.NameDescBody{} 35 | if err := ctx.ParseBody(&body); err != nil { 36 | return err 37 | } 38 | 39 | res, err := a.blls.Module.Create(ctx, req.Product, body.Name, body.Desc) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | return ctx.OkJSON(res) 45 | } 46 | 47 | // Update .. 48 | func (a *Module) Update(ctx *gear.Context) error { 49 | req := tpl.ProductModuleURL{} 50 | if err := ctx.ParseURL(&req); err != nil { 51 | return err 52 | } 53 | 54 | body := tpl.ModuleUpdateBody{} 55 | if err := ctx.ParseBody(&body); err != nil { 56 | return err 57 | } 58 | 59 | res, err := a.blls.Module.Update(ctx, req.Product, req.Module, body) 60 | if err != nil { 61 | return err 62 | } 63 | return ctx.OkJSON(res) 64 | } 65 | 66 | // Offline .. 67 | func (a *Module) Offline(ctx *gear.Context) error { 68 | req := tpl.ProductModuleURL{} 69 | if err := ctx.ParseURL(&req); err != nil { 70 | return err 71 | } 72 | res, err := a.blls.Module.Offline(ctx, req.Product, req.Module) 73 | if err != nil { 74 | return err 75 | } 76 | return ctx.OkJSON(res) 77 | } 78 | -------------------------------------------------------------------------------- /src/logging/logger.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/teambition/gear" 8 | gearLogging "github.com/teambition/gear/logging" 9 | "github.com/teambition/urbs-setting/src/conf" 10 | ) 11 | 12 | func init() { 13 | Logger.SetJSONLog() 14 | AccessLogger.SetJSONLog() 15 | 16 | // AccessLogger is not needed to set level. 17 | err := gearLogging.SetLoggerLevel(Logger, conf.Config.Logger.Level) 18 | if err != nil { 19 | Logger.Err(err) 20 | } 21 | } 22 | 23 | // AccessLogger is used for access log 24 | var AccessLogger = gearLogging.New(os.Stdout) 25 | 26 | // Logger is used for the server. 27 | var Logger = gearLogging.New(os.Stderr) 28 | 29 | // SrvLog returns a Log with kind of server. 30 | func SrvLog(format string, args ...interface{}) gearLogging.Log { 31 | return gearLogging.Log{ 32 | "kind": "server", 33 | "message": fmt.Sprintf(format, args...), 34 | } 35 | } 36 | 37 | // Panicf produce a "Emergency" log into the Logger. 38 | func Panicf(format string, args ...interface{}) { 39 | Logger.Panic(SrvLog(format, args...)) 40 | } 41 | 42 | // Errf produce a "Error" log into the Logger. 43 | func Errf(format string, args ...interface{}) { 44 | Logger.Err(SrvLog(format, args...)) 45 | } 46 | 47 | // Warningf produce a "Warning" log into the Logger. 48 | func Warningf(format string, args ...interface{}) { 49 | Logger.Warning(SrvLog(format, args...)) 50 | } 51 | 52 | // Infof produce a "Informational" log into the Logger. 53 | func Infof(format string, args ...interface{}) { 54 | Logger.Info(SrvLog(format, args...)) 55 | } 56 | 57 | // Debugf produce a "Debug" log into the Logger. 58 | func Debugf(format string, args ...interface{}) { 59 | Logger.Debug(SrvLog(format, args...)) 60 | } 61 | 62 | // FromCtx retrieve the Log instance for the AccessLogger. 63 | func FromCtx(ctx *gear.Context) gearLogging.Log { 64 | return AccessLogger.FromCtx(ctx) 65 | } 66 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Urbs-Setting Release 2 | on: 3 | push: 4 | # Sequence of patterns matched against refs/tags 5 | tags: 6 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 7 | 8 | jobs: 9 | build: 10 | name: Build And Upload Release Asset 11 | runs-on: ubuntu-latest 12 | container: golang:1.14.2 13 | steps: 14 | - name: Check out code into the Go module directory 15 | uses: actions/checkout@v2 16 | 17 | - name: Get dependencies 18 | run: | 19 | go version 20 | go get -v -d ./... 21 | 22 | - name: Build project # This would actually build your project, using zip for an example artifact 23 | run: | 24 | make build-linux 25 | cd dist 26 | tar -czf urbs-setting.linux-amd64.tar.gz urbs-setting 27 | 28 | - name: Create Release 29 | id: create_release 30 | uses: actions/create-release@v1 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | with: 34 | tag_name: ${{ github.ref }} 35 | release_name: Release ${{ github.ref }} 36 | draft: false 37 | prerelease: false 38 | 39 | - name: Upload Release Asset 40 | id: upload-release-asset 41 | uses: actions/upload-release-asset@v1 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | with: 45 | upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps 46 | asset_path: ./dist/urbs-setting.linux-amd64.tar.gz 47 | asset_name: urbs-setting.linux-amd64.tar.gz 48 | asset_content_type: application/octet-stream 49 | -------------------------------------------------------------------------------- /src/api/app.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | 7 | "github.com/teambition/gear" 8 | tracing "github.com/teambition/gear-tracing" 9 | 10 | "github.com/teambition/urbs-setting/src/logging" 11 | "github.com/teambition/urbs-setting/src/util" 12 | ) 13 | 14 | // AppName 服务名 15 | var AppName = "urbs-setting" 16 | 17 | // AppVersion 服务版本 18 | var AppVersion = "unknown" 19 | 20 | // BuildTime 镜像生成时间 21 | var BuildTime = "unknown" 22 | 23 | // GitSHA1 镜像对应 git commit id 24 | var GitSHA1 = "unknown" 25 | 26 | // GetVersion ... 27 | func GetVersion() map[string]string { 28 | return map[string]string{ 29 | "name": AppName, 30 | "version": AppVersion, 31 | "buildTime": BuildTime, 32 | "gitSHA1": GitSHA1, 33 | } 34 | } 35 | 36 | // NewApp ... 37 | func NewApp() *gear.App { 38 | app := gear.New() 39 | 40 | app.Set(gear.SetTrustedProxy, true) 41 | app.Set(gear.SetBodyParser, gear.DefaultBodyParser(2<<22)) // 8MB 42 | // ignore TLS handshake error 43 | app.Set(gear.SetLogger, log.New(gear.DefaultFilterWriter(), "", 0)) 44 | 45 | app.Set(gear.SetParseError, func(err error) gear.HTTPError { 46 | msg := err.Error() 47 | 48 | if strings.Contains(msg, "Error 1062: Duplicate") { 49 | return gear.ErrConflict.WithMsg(msg) 50 | } 51 | 52 | return gear.ParseError(err) 53 | }) 54 | 55 | // used for health check, so ingore logger 56 | app.Use(func(ctx *gear.Context) error { 57 | if ctx.Path == "/" || ctx.Path == "/version" { 58 | return ctx.OkJSON(GetVersion()) 59 | } 60 | 61 | return nil 62 | }) 63 | 64 | app.Use(tracing.New("EntryPoint")) 65 | if app.Env() != "test" { 66 | app.UseHandler(logging.AccessLogger) 67 | } 68 | 69 | err := util.DigInvoke(func(routers []*gear.Router) error { 70 | for _, router := range routers { 71 | app.UseHandler(router) 72 | } 73 | return nil 74 | }) 75 | 76 | if err != nil { 77 | logging.Panicf("DigInvoke error: %v", err) 78 | } 79 | 80 | return app 81 | } 82 | -------------------------------------------------------------------------------- /src/tpl/setting_rule.go: -------------------------------------------------------------------------------- 1 | package tpl 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/teambition/urbs-setting/src/schema" 7 | "github.com/teambition/urbs-setting/src/service" 8 | ) 9 | 10 | // SettingRuleBody ... 11 | type SettingRuleBody struct { 12 | schema.PercentRule 13 | Value string `json:"value"` 14 | } 15 | 16 | // SettingRuleInfo ... 17 | type SettingRuleInfo struct { 18 | ID int64 `json:"-"` 19 | HID string `json:"hid"` 20 | SettingHID string `json:"settingHID"` 21 | Kind string `json:"kind"` 22 | Rule interface{} `json:"rule"` 23 | Value string `json:"value"` 24 | Release int64 `json:"release"` 25 | CreatedAt time.Time `json:"createdAt"` 26 | UpdatedAt time.Time `json:"updatedAt"` 27 | } 28 | 29 | // SettingRuleInfoFrom ... 30 | func SettingRuleInfoFrom(settingRule schema.SettingRule) SettingRuleInfo { 31 | return SettingRuleInfo{ 32 | ID: settingRule.ID, 33 | HID: service.IDToHID(settingRule.ID, "setting_rule"), 34 | SettingHID: service.IDToHID(settingRule.SettingID, "setting"), 35 | Kind: settingRule.Kind, 36 | Rule: schema.ToRuleObject(settingRule.Kind, settingRule.Rule), 37 | Value: settingRule.Value, 38 | Release: settingRule.Release, 39 | CreatedAt: settingRule.CreatedAt, 40 | UpdatedAt: settingRule.UpdatedAt, 41 | } 42 | } 43 | 44 | // SettingRulesInfoFrom ... 45 | func SettingRulesInfoFrom(settingRules []schema.SettingRule) []SettingRuleInfo { 46 | res := make([]SettingRuleInfo, len(settingRules)) 47 | for i, l := range settingRules { 48 | res[i] = SettingRuleInfoFrom(l) 49 | } 50 | return res 51 | } 52 | 53 | // SettingRulesInfoRes ... 54 | type SettingRulesInfoRes struct { 55 | SuccessResponseType 56 | Result []SettingRuleInfo `json:"result"` // 空数组也保留 57 | } 58 | 59 | // SettingRuleInfoRes ... 60 | type SettingRuleInfoRes struct { 61 | SuccessResponseType 62 | Result SettingRuleInfo `json:"result"` // 空数组也保留 63 | } 64 | -------------------------------------------------------------------------------- /doc/paths_module.yaml: -------------------------------------------------------------------------------- 1 | # Module API 2 | /v1/products/{product}/modules: 3 | get: 4 | tags: 5 | - Module 6 | summary: 读取产品功能模块列表,支持分页,按照创建时间倒序 7 | security: 8 | - HeaderAuthorizationJWT: {} 9 | parameters: 10 | - $ref: '#/components/parameters/HeaderAuthorization' 11 | - $ref: "#/components/parameters/PathProduct" 12 | - $ref: "#/components/parameters/QueryPageSize" 13 | - $ref: "#/components/parameters/QueryPageToken" 14 | - $ref: "#/components/parameters/QueryQ" 15 | responses: 16 | '200': 17 | $ref: '#/components/responses/ModulesRes' 18 | post: 19 | tags: 20 | - Module 21 | summary: 添加产品功能模块,功能模块 name 在产品下必须唯一 22 | security: 23 | - HeaderAuthorizationJWT: {} 24 | parameters: 25 | - $ref: '#/components/parameters/HeaderAuthorization' 26 | - $ref: "#/components/parameters/PathProduct" 27 | requestBody: 28 | $ref: '#/components/requestBodies/NameDescBody' 29 | responses: 30 | '200': 31 | $ref: '#/components/responses/ModuleRes' 32 | 33 | /v1/products/{product}/modules/{module}: 34 | put: 35 | tags: 36 | - Module 37 | summary: 更新指定 product name 的产品 38 | security: 39 | - HeaderAuthorizationJWT: {} 40 | parameters: 41 | - $ref: '#/components/parameters/HeaderAuthorization' 42 | - $ref: "#/components/parameters/PathProduct" 43 | - $ref: "#/components/parameters/PathModule" 44 | requestBody: 45 | $ref: '#/components/requestBodies/ModuleUpdateBody' 46 | responses: 47 | '200': 48 | $ref: '#/components/responses/ModuleRes' 49 | 50 | /v1/products/{product}/modules/{module}:offline: 51 | put: 52 | tags: 53 | - Module 54 | summary: 将指定产品功能模块下线,此操作会将功能模块名下的所有配置项都下线,所有设置给用户或群组的对应配置项也会被移除! 55 | security: 56 | - HeaderAuthorizationJWT: {} 57 | parameters: 58 | - $ref: '#/components/parameters/HeaderAuthorization' 59 | - $ref: "#/components/parameters/PathProduct" 60 | - $ref: "#/components/parameters/PathModule" 61 | responses: 62 | '200': 63 | $ref: '#/components/responses/BoolRes' -------------------------------------------------------------------------------- /src/api/product.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/teambition/gear" 5 | "github.com/teambition/urbs-setting/src/bll" 6 | "github.com/teambition/urbs-setting/src/tpl" 7 | ) 8 | 9 | // Product .. 10 | type Product struct { 11 | blls *bll.Blls 12 | } 13 | 14 | // List .. 15 | func (a *Product) List(ctx *gear.Context) error { 16 | req := tpl.Pagination{} 17 | if err := ctx.ParseURL(&req); err != nil { 18 | return err 19 | } 20 | 21 | res, err := a.blls.Product.List(ctx, req) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | return ctx.OkJSON(res) 27 | } 28 | 29 | // Create .. 30 | func (a *Product) Create(ctx *gear.Context) error { 31 | body := tpl.NameDescBody{} 32 | if err := ctx.ParseBody(&body); err != nil { 33 | return err 34 | } 35 | 36 | res, err := a.blls.Product.Create(ctx, body.Name, body.Desc) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | return ctx.OkJSON(res) 42 | } 43 | 44 | // Update .. 45 | func (a *Product) Update(ctx *gear.Context) error { 46 | req := tpl.ProductURL{} 47 | if err := ctx.ParseURL(&req); err != nil { 48 | return err 49 | } 50 | 51 | body := tpl.ProductUpdateBody{} 52 | if err := ctx.ParseBody(&body); err != nil { 53 | return err 54 | } 55 | 56 | res, err := a.blls.Product.Update(ctx, req.Product, body) 57 | if err != nil { 58 | return err 59 | } 60 | return ctx.OkJSON(res) 61 | } 62 | 63 | // Offline .. 64 | func (a *Product) Offline(ctx *gear.Context) error { 65 | req := tpl.ProductURL{} 66 | if err := ctx.ParseURL(&req); err != nil { 67 | return err 68 | } 69 | res, err := a.blls.Product.Offline(ctx, req.Product) 70 | if err != nil { 71 | return err 72 | } 73 | return ctx.OkJSON(res) 74 | } 75 | 76 | // Delete .. 77 | func (a *Product) Delete(ctx *gear.Context) error { 78 | req := tpl.ProductURL{} 79 | if err := ctx.ParseURL(&req); err != nil { 80 | return err 81 | } 82 | res, err := a.blls.Product.Delete(ctx, req.Product) 83 | if err != nil { 84 | return err 85 | } 86 | return ctx.OkJSON(res) 87 | } 88 | 89 | // Statistics .. 90 | func (a *Product) Statistics(ctx *gear.Context) error { 91 | req := tpl.ProductURL{} 92 | if err := ctx.ParseURL(&req); err != nil { 93 | return err 94 | } 95 | res, err := a.blls.Product.Statistics(ctx, req.Product) 96 | if err != nil { 97 | return err 98 | } 99 | return ctx.OkJSON(tpl.ProductStatisticsRes{Result: *res}) 100 | } 101 | -------------------------------------------------------------------------------- /src/middleware/auth.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "time" 5 | 6 | otgo "github.com/open-trust/ot-go-lib" 7 | "github.com/teambition/gear" 8 | auth "github.com/teambition/gear-auth" 9 | authjwt "github.com/teambition/gear-auth/jwt" 10 | "github.com/teambition/urbs-setting/src/conf" 11 | "github.com/teambition/urbs-setting/src/logging" 12 | ) 13 | 14 | func init() { 15 | // otgo.Debugging = logging.Logger // 开启 otgo debug 日志 16 | 17 | otConf := conf.Config.OpenTrust 18 | keys := conf.Config.AuthKeys 19 | if len(keys) > 0 { 20 | Auther = auth.New(authjwt.StrToKeys(keys...)...) 21 | Auther.JWT().SetExpiresIn(time.Minute * 10) 22 | } 23 | if err := otConf.OTID.Validate(); err == nil { 24 | otVerifier, err = otgo.NewVerifier(conf.Config.GlobalCtx, otConf.OTID, false, 25 | otConf.DomainPublicKeys...) 26 | if err != nil { 27 | logging.Panicf("Parse Open Trust config failed: %s", err) 28 | } 29 | } 30 | if err := otConf.LegacyOTID.Validate(); err == nil { 31 | otLegacyVerifier, err = otgo.NewVerifier(conf.Config.GlobalCtx, otConf.LegacyOTID, false, 32 | otConf.DomainPublicKeys...) 33 | if err != nil { 34 | logging.Panicf("Parse Open Trust config failed: %s", err) 35 | } 36 | } 37 | 38 | if otVerifier == nil && Auther == nil { 39 | logging.Warningf("`auth_keys` is empty, Auth middleware will not be executed.") 40 | } 41 | } 42 | 43 | var otVerifier *otgo.Verifier 44 | var otLegacyVerifier *otgo.Verifier 45 | 46 | // Auther 是基于 JWT 的身份验证,当 config.auth_keys 配置了才会启用 47 | var Auther *auth.Auth 48 | 49 | // Auth 验证请求者身份,如果验证失败,则返回 401 的 gear.HTTPError 50 | func Auth(ctx *gear.Context) error { 51 | if otVerifier != nil { 52 | token := otgo.ExtractTokenFromHeader(ctx.Req.Header) 53 | if token == "" { 54 | return gear.ErrUnauthorized.WithMsg("invalid authorization token") 55 | } 56 | 57 | vid, err := otVerifier.ParseOTVID(token) 58 | if err != nil && otLegacyVerifier != nil { 59 | vid, err = otLegacyVerifier.ParseOTVID(token) 60 | } 61 | if err != nil { 62 | if Auther != nil { // 兼容老的 jwt 验证 63 | return oldAuth(ctx) 64 | } 65 | return gear.ErrUnauthorized.WithMsg("authorization token verification failed") 66 | } 67 | 68 | logging.AccessLogger.SetTo(ctx, "subject", vid.ID.String()) 69 | return nil 70 | } 71 | return oldAuth(ctx) 72 | } 73 | 74 | func oldAuth(ctx *gear.Context) error { 75 | if Auther != nil { 76 | claims, err := Auther.FromCtx(ctx) 77 | if err != nil { 78 | return err 79 | } 80 | if sub, ok := claims.Subject(); ok { 81 | logging.AccessLogger.SetTo(ctx, "jwt_sub", sub) 82 | } 83 | if jti, ok := claims.JWTID(); ok { 84 | logging.AccessLogger.SetTo(ctx, "jwt_id", jti) 85 | } 86 | } 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /src/api/app_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/DavidCai1993/request" 9 | "github.com/doug-martin/goqu/v9" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/teambition/gear" 12 | "github.com/teambition/urbs-setting/src/service" 13 | "github.com/teambition/urbs-setting/src/util" 14 | ) 15 | 16 | type TestTools struct { 17 | DB *goqu.Database 18 | App *gear.App 19 | Host string 20 | } 21 | 22 | func SetUpTestTools() (tt *TestTools, cleanup func()) { 23 | tt = &TestTools{} 24 | tt.App = NewApp() 25 | srv := tt.App.Start() 26 | tt.Host = "http://" + srv.Addr().String() 27 | 28 | err := util.DigInvoke(func(sql *service.SQL) error { 29 | tt.DB = sql.DB 30 | return nil 31 | }) 32 | if err != nil { 33 | panic(err) 34 | } 35 | 36 | return tt, func() { 37 | srv.Close() 38 | } 39 | } 40 | 41 | func TestMain(m *testing.M) { 42 | tt, cleanup := SetUpTestTools() 43 | tt.DB.Exec("TRUNCATE TABLE urbs_user;") 44 | tt.DB.Exec("TRUNCATE TABLE urbs_group;") 45 | tt.DB.Exec("TRUNCATE TABLE urbs_product;") 46 | tt.DB.Exec("TRUNCATE TABLE urbs_label;") 47 | tt.DB.Exec("TRUNCATE TABLE urbs_module;") 48 | tt.DB.Exec("TRUNCATE TABLE urbs_setting;") 49 | tt.DB.Exec("TRUNCATE TABLE user_group;") 50 | tt.DB.Exec("TRUNCATE TABLE user_label;") 51 | tt.DB.Exec("TRUNCATE TABLE user_setting;") 52 | tt.DB.Exec("TRUNCATE TABLE group_label;") 53 | tt.DB.Exec("TRUNCATE TABLE group_setting;") 54 | tt.DB.Exec("TRUNCATE TABLE label_rule;") 55 | tt.DB.Exec("TRUNCATE TABLE setting_rule;") 56 | tt.DB.Exec("TRUNCATE TABLE urbs_statistic;") 57 | tt.DB.Exec("TRUNCATE TABLE urbs_lock;") 58 | cleanup() 59 | os.Exit(m.Run()) 60 | } 61 | 62 | func TestApp(t *testing.T) { 63 | tt, cleanup := SetUpTestTools() 64 | defer cleanup() 65 | 66 | t.Run(`app should work`, func(t *testing.T) { 67 | assert := assert.New(t) 68 | 69 | res, err := request.Get(tt.Host).End() 70 | json := map[string]string{} 71 | res.JSON(&json) 72 | 73 | assert.Nil(err) 74 | assert.Equal(200, res.StatusCode) 75 | assert.Equal("urbs-setting", json["name"]) 76 | assert.NotEqual("", json["version"]) 77 | assert.NotEqual("", json["gitSHA1"]) 78 | assert.NotEqual("", json["buildTime"]) 79 | }) 80 | 81 | t.Run(`"GET /version" should work`, func(t *testing.T) { 82 | assert := assert.New(t) 83 | 84 | res, err := request.Get(fmt.Sprintf("%s/version", tt.Host)).End() 85 | json := map[string]string{} 86 | res.JSON(&json) 87 | 88 | assert.Nil(err) 89 | assert.Equal(200, res.StatusCode) 90 | assert.Equal("urbs-setting", json["name"]) 91 | assert.NotEqual("", json["version"]) 92 | assert.NotEqual("", json["gitSHA1"]) 93 | assert.NotEqual("", json["buildTime"]) 94 | }) 95 | } 96 | -------------------------------------------------------------------------------- /src/bll/module.go: -------------------------------------------------------------------------------- 1 | package bll 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/teambition/gear" 7 | "github.com/teambition/urbs-setting/src/model" 8 | "github.com/teambition/urbs-setting/src/schema" 9 | "github.com/teambition/urbs-setting/src/tpl" 10 | ) 11 | 12 | // Module ... 13 | type Module struct { 14 | ms *model.Models 15 | } 16 | 17 | // List 返回产品下的功能模块列表,TODO:支持分页 18 | func (b *Module) List(ctx context.Context, productName string, pg tpl.Pagination) (*tpl.ModulesRes, error) { 19 | productID, err := b.ms.Product.AcquireID(ctx, productName) 20 | if err != nil { 21 | return nil, err 22 | } 23 | modules, total, err := b.ms.Module.Find(ctx, productID, pg) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | res := &tpl.ModulesRes{Result: modules} 29 | res.TotalSize = total 30 | if len(res.Result) > pg.PageSize { 31 | res.NextPageToken = tpl.IDToPageToken(res.Result[pg.PageSize].ID) 32 | res.Result = res.Result[:pg.PageSize] 33 | } 34 | return res, nil 35 | } 36 | 37 | // Create 创建功能模块 38 | func (b *Module) Create(ctx context.Context, productName, moduleName, desc string) (*tpl.ModuleRes, error) { 39 | productID, err := b.ms.Product.AcquireID(ctx, productName) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | module := &schema.Module{ProductID: productID, Name: moduleName, Desc: desc} 45 | if err = b.ms.Module.Create(ctx, module); err != nil { 46 | return nil, err 47 | } 48 | return &tpl.ModuleRes{Result: *module}, nil 49 | } 50 | 51 | // Update ... 52 | func (b *Module) Update(ctx context.Context, productName, moduleName string, body tpl.ModuleUpdateBody) (*tpl.ModuleRes, error) { 53 | productID, err := b.ms.Product.AcquireID(ctx, productName) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | module, err := b.ms.Module.Acquire(ctx, productID, moduleName) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | module, err = b.ms.Module.Update(ctx, module.ID, body.ToMap()) 64 | if err != nil { 65 | return nil, err 66 | } 67 | return &tpl.ModuleRes{Result: *module}, nil 68 | } 69 | 70 | // Offline 下线功能模块 71 | func (b *Module) Offline(ctx context.Context, productName, moduleName string) (*tpl.BoolRes, error) { 72 | productID, err := b.ms.Product.AcquireID(ctx, productName) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | res := &tpl.BoolRes{Result: false} 78 | module, err := b.ms.Module.FindByName(ctx, productID, moduleName, "id, `offline_at`") 79 | if err != nil { 80 | return nil, err 81 | } 82 | if module == nil { 83 | return nil, gear.ErrNotFound.WithMsgf("module %s not found", moduleName) 84 | } 85 | if module.OfflineAt == nil { 86 | if err = b.ms.Module.Offline(ctx, module.ID); err != nil { 87 | return nil, err 88 | } 89 | res.Result = true 90 | } 91 | return res, nil 92 | } 93 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: dev test image doc 2 | 3 | APP_NAME := urbs-setting 4 | APP_PATH := github.com/teambition/urbs-setting 5 | APP_VERSION := $(shell git describe --tags --always --match "v[0-9]*") 6 | 7 | dev: 8 | @CONFIG_FILE_PATH=${PWD}/config/default.yml APP_ENV=development go run main.go 9 | 10 | test: 11 | @CONFIG_FILE_PATH=${PWD}/config/test.yml APP_ENV=test go test ./... 12 | 13 | doc: 14 | # https://github.com/Mermade/widdershins 15 | # Install: npm i -g widdershins 16 | echo "# Content generated by 'make doc'. DO NOT EDIT.\n" > doc/openapi.yaml 17 | cat doc/openapi_header.yaml >> doc/openapi.yaml 18 | cat doc/paths_version.yaml >> doc/openapi.yaml 19 | cat doc/paths_user.yaml >> doc/openapi.yaml 20 | cat doc/paths_group.yaml >> doc/openapi.yaml 21 | cat doc/paths_product.yaml >> doc/openapi.yaml 22 | cat doc/paths_label.yaml >> doc/openapi.yaml 23 | cat doc/paths_module.yaml >> doc/openapi.yaml 24 | cat doc/paths_setting.yaml >> doc/openapi.yaml 25 | widdershins --language_tabs 'shell:Shell' 'http:HTTP' --summary doc/openapi.yaml -o doc/openapi.md 26 | 27 | BUILD_TIME := $(shell date -u +"%FT%TZ") 28 | BUILD_COMMIT := $(shell git rev-parse HEAD) 29 | 30 | .PHONY: build build-tool 31 | build: 32 | @mkdir -p ./dist 33 | GO111MODULE=on go build -ldflags "-X ${APP_PATH}/src/api.AppName=${APP_NAME} \ 34 | -X ${APP_PATH}/src/api.AppVersion=${APP_VERSION} \ 35 | -X ${APP_PATH}/src/api.BuildTime=${BUILD_TIME} \ 36 | -X ${APP_PATH}/src/api.GitSHA1=${BUILD_COMMIT}" \ 37 | -o ./dist/urbs-setting main.go 38 | build-linux: 39 | @mkdir -p ./dist 40 | GO111MODULE=on CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X ${APP_PATH}/src/api.AppName=${APP_NAME} \ 41 | -X ${APP_PATH}/src/api.AppVersion=${APP_VERSION} \ 42 | -X ${APP_PATH}/src/api.BuildTime=${BUILD_TIME} \ 43 | -X ${APP_PATH}/src/api.GitSHA1=${BUILD_COMMIT}" \ 44 | -o ./dist/urbs-setting main.go 45 | 46 | PKG_LIST := $(shell go list ./... | grep -v /vendor/) 47 | GO_FILES := $(shell find . -name '*.go' | grep -v /vendor/) 48 | 49 | .PHONY: lint 50 | lint: 51 | @hash golint > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ 52 | go get -u golang.org/x/lint/golint; \ 53 | fi 54 | @golint -set_exit_status ${PKG_LIST} 55 | 56 | .PHONY: fmt-check 57 | fmt-check: 58 | test -z "$(shell gofmt -d -e ${GO_FILES})" 59 | 60 | .PHONY: misspell-check 61 | misspell-check: 62 | @hash misspell > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ 63 | go get -u github.com/client9/misspell/cmd/misspell; \ 64 | fi 65 | @misspell -error $(GO_FILES) 66 | 67 | .PHONY: coverhtml 68 | coverhtml: 69 | @mkdir -p coverage 70 | @CONFIG_FILE_PATH=${PWD}/config/test-local.yml go test -coverprofile=coverage/cover.out ./... 71 | @go tool cover -html=coverage/cover.out -o coverage/coverage.html 72 | @go tool cover -func=coverage/cover.out | tail -n 1 73 | 74 | DOCKER_IMAGE_TAG := ${APP_NAME}:latest 75 | .PHONY: image 76 | image: 77 | docker build --rm -t ${DOCKER_IMAGE_TAG} . 78 | -------------------------------------------------------------------------------- /src/service/mysql.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "net/url" 8 | "strings" 9 | "time" 10 | 11 | "github.com/doug-martin/goqu/v9" 12 | mysqlDialect "github.com/doug-martin/goqu/v9/dialect/mysql" 13 | _ "github.com/go-sql-driver/mysql" // go-sql-driver 14 | "github.com/teambition/urbs-setting/src/conf" 15 | "github.com/teambition/urbs-setting/src/logging" 16 | "github.com/teambition/urbs-setting/src/util" 17 | ) 18 | 19 | func init() { 20 | util.DigProvide(NewDB) 21 | goqu.RegisterDialect("default", mysqlDialect.DialectOptions()) // make mysql dialect as default too. 22 | } 23 | 24 | // SQL ... 25 | type SQL struct { 26 | db *sql.DB 27 | DB *goqu.Database 28 | RdDB *goqu.Database 29 | } 30 | 31 | // DBStats ... 32 | func (s *SQL) DBStats() sql.DBStats { 33 | return s.db.Stats() 34 | } 35 | 36 | // NewDB ... 37 | func NewDB() *SQL { 38 | db := connectDB(conf.Config.MySQL) 39 | rdDB := db 40 | if conf.Config.MySQLRd.Host != "" { 41 | rdDB = connectDB(conf.Config.MySQLRd) 42 | } 43 | 44 | dialect := goqu.Dialect("mysql") 45 | return &SQL{ 46 | db: db, 47 | DB: dialect.DB(db), 48 | RdDB: dialect.DB(rdDB), 49 | } 50 | } 51 | 52 | func connectDB(cfg conf.SQL) *sql.DB { 53 | if cfg.MaxIdleConns <= 0 { 54 | cfg.MaxIdleConns = 8 55 | } 56 | 57 | if cfg.MaxOpenConns <= 0 { 58 | cfg.MaxOpenConns = 64 59 | } 60 | 61 | if cfg.User == "" || cfg.Password == "" || cfg.Host == "" { 62 | logging.Panicf("Invalid SQL DB config %s:%s@(%s)/%s", cfg.User, cfg.Password, cfg.Host, cfg.Database) 63 | } 64 | 65 | parameters, err := url.ParseQuery(cfg.Parameters) 66 | if err != nil { 67 | logging.Panicf("Invalid SQL DB parameters %s", cfg.Parameters) 68 | } 69 | // 强制使用 70 | parameters.Set("collation", "utf8mb4_general_ci") 71 | parameters.Set("parseTime", "true") 72 | 73 | // https://github.com/go-sql-driver/mysql#parameters 74 | url := fmt.Sprintf(`%s:%s@(%s)/%s?%s`, cfg.User, cfg.Password, cfg.Host, cfg.Database, parameters.Encode()) 75 | db, err := sql.Open("mysql", url) 76 | if err == nil { 77 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 78 | err = db.PingContext(ctx) 79 | cancel() 80 | } 81 | if err != nil { 82 | url = strings.Replace(url, cfg.Password, cfg.Password[0:4]+"***", 1) 83 | logging.Panicf("SQL DB connect failed %s, with config %s", err, url) 84 | } 85 | 86 | // SetMaxIdleCons 设置连接池中的最大闲置连接数。 87 | db.SetMaxIdleConns(cfg.MaxIdleConns) 88 | // SetMaxOpenCons 设置数据库的最大连接数量。 89 | db.SetMaxOpenConns(cfg.MaxOpenConns) 90 | // SetConnMaxLifetiment 设置连接的最大可复用时间。 91 | // db.SetConnMaxLifetime(time.Hour) 92 | return db 93 | } 94 | 95 | // DeResult ... 96 | func DeResult(re sql.Result, err error) (int64, error) { 97 | if err != nil { 98 | return 0, err 99 | } 100 | return re.RowsAffected() 101 | } 102 | -------------------------------------------------------------------------------- /src/tpl/pagination.go: -------------------------------------------------------------------------------- 1 | package tpl 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/teambition/gear" 8 | "github.com/teambition/urbs-setting/src/service" 9 | ) 10 | 11 | // Search 搜索 12 | type Search struct { 13 | Q string `json:"q" query:"q"` 14 | } 15 | 16 | // Validate escape and build MySQL LIKE pattern 17 | func (s *Search) Validate() error { 18 | if s.Q != "" { 19 | if len(s.Q) <= 2 { 20 | return gear.ErrBadRequest.WithMsgf("too small query: %s", s.Q) 21 | } 22 | s.Q = strings.ReplaceAll(s.Q, `\`, "-") 23 | s.Q = strings.ReplaceAll(s.Q, "%", `\%`) 24 | s.Q = strings.ReplaceAll(s.Q, "_", `\_`) 25 | } 26 | if s.Q != "" { 27 | s.Q = s.Q + "%" // %q% 在大数据表(如user表)下开销太大 28 | } 29 | return nil 30 | } 31 | 32 | // Pagination 分页 33 | type Pagination struct { 34 | Search 35 | PageToken string `json:"pageToken" query:"pageToken"` 36 | PageSize int `json:"pageSize,omitempty" query:"pageSize"` 37 | Skip int `json:"skip,omitempty" query:"skip"` 38 | } 39 | 40 | // Validate ... 41 | func (pg *Pagination) Validate() error { 42 | if pg.Skip < 0 { 43 | pg.Skip = 0 44 | } 45 | 46 | if pg.PageSize > 1000 { 47 | return gear.ErrBadRequest.WithMsgf("pageSize %v should not great than 1000", pg.PageSize) 48 | } 49 | 50 | if pg.PageSize <= 0 { 51 | pg.PageSize = 10 52 | } 53 | 54 | if err := pg.Search.Validate(); err != nil { 55 | return err 56 | } 57 | 58 | return nil 59 | } 60 | 61 | // TokenToID 把 pageToken 转换为 int64 62 | func (pg *Pagination) TokenToID() int64 { 63 | return PageTokenToID(pg.PageToken) 64 | } 65 | 66 | // TokenToTimestamp 把 pageToken 转换为 timestamp (ms) 67 | func (pg *Pagination) TokenToTimestamp(defaultTime ...time.Time) int64 { 68 | return PageTokenToTimestamp(pg.PageToken, defaultTime...) 69 | } 70 | 71 | // PageTokenToID 把 pageToken 转换为 int64 72 | func PageTokenToID(pageToken string) int64 { 73 | if !strings.HasPrefix(pageToken, "h.") { 74 | return 9223372036854775807 75 | } 76 | return service.HIDToID(pageToken[2:]) 77 | } 78 | 79 | // IDToPageToken 把 int64 转换成 pageToken 80 | func IDToPageToken(id int64) string { 81 | if id <= 0 { 82 | return "" 83 | } 84 | return "h." + service.IDToHID(id) 85 | } 86 | 87 | // PageTokenToTimestamp 把 pageToken 转换为 timestamp 88 | func PageTokenToTimestamp(pageToken string, defaultTime ...time.Time) int64 { 89 | t := time.Unix(0, 0) 90 | if len(defaultTime) > 0 { 91 | t = defaultTime[0] 92 | } 93 | if !strings.HasPrefix(pageToken, "t.") { 94 | return t.Unix()*1000 + int64(t.UTC().Nanosecond()/1000000) 95 | } 96 | 97 | return service.HIDToID(pageToken[2:]) 98 | } 99 | 100 | // TimeToPageToken 把 time 转换成 pageToken 101 | func TimeToPageToken(t time.Time) string { 102 | s := t.Unix()*1000 + int64(t.UTC().Nanosecond()/1000000) 103 | if s <= 0 { 104 | return "" 105 | } 106 | return "t." + service.IDToHID(s) 107 | } 108 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file starting from version **v1.0.0**. 4 | This project adheres to [Semantic Versioning](http://semver.org/). 5 | 6 | ----- 7 | ## [1.8.0] - 2020-09-16 8 | 9 | **Change:** 10 | - Support parent-child relationship label. 11 | 12 | ## [1.7.1] - 2020-08-13 13 | 14 | **Change:** 15 | - Don't apply label rule in a product when someone exists. 16 | 17 | ## [1.7.0] - 2020-07-08 18 | 19 | **Change:** 20 | - Support primary and secondary mysql connections. 21 | 22 | ## [1.6.0] - 2020-07-07 23 | 24 | **Change:** 25 | - `GET /users/:uid/labels:cache` and `GET /v1/users/:uid/settings:unionAll` support anonymous user, uid should with prefix `anon-`. 26 | - `GET /v1/users/:uid/settings` and `GET /v1/groups/:uid/settings` support `channel` and `client` query. 27 | 28 | ## [1.5.0] - 2020-06-08 29 | 30 | **Change:** 31 | 32 | - Add API `DELETE /v1/products/{product}/modules/{module}/settings/{setting}:cleanup` that cleanup all rules, users and groups on the setting. 33 | - Add API `DELETE /v1/products/{product}/labels/{label}:cleanup` that cleanup all rules, users and groups on the label. 34 | 35 | ## [1.4.0] - 2020-05-27 36 | 37 | **Change:** 38 | 39 | - Change user's setting and label API. 40 | - Change group's setting and label API. 41 | - Use [goqu](github.com/doug-martin/goqu/v9) instead of gorm. 42 | - Support more query parameters for settings API. 43 | 44 | ## [1.3.3] - 2020-05-18 45 | 46 | **Fixed:** 47 | 48 | - Fix API's totalSize count. 49 | - Fix tracing middleware. 50 | 51 | ## [1.3.2] - 2020-05-13 52 | 53 | **Change:** 54 | 55 | - Create setting with more params. 56 | 57 | **Fixed:** 58 | 59 | - Fix `name` field for `urbs_statistic` table and `urbs_lock` table. 60 | 61 | ## [1.3.1] - 2020-05-11 62 | 63 | **Change:** 64 | 65 | - Create label with more params. 66 | 67 | ## [1.3.0] - 2020-05-08 68 | 69 | **Change:** 70 | 71 | - Support label rule and setting rule. 72 | - Support search for list APIs. 73 | - Change APIs to camelCase, see https://github.com/json-api/json-api/issues/1255. 74 | 75 | ## [1.2.3] - 2020-04-03 76 | 77 | **Change:** 78 | 79 | - Improve cached labels API. 80 | - Add module, setting, label documents. 81 | 82 | ## [1.2.2] - 2020-04-01 83 | 84 | **Change:** 85 | 86 | - Improve swagger document. 87 | - Add user and group documents. 88 | 89 | **Fixed:** 90 | 91 | - Fix settings API. 92 | 93 | ## [1.2.1] - 2020-03-29 94 | 95 | **Change:** 96 | 97 | - Update Gear version. 98 | 99 | ## [1.2.0] - 2020-03-25 100 | 101 | **Change:** 102 | 103 | - Add test cases for all APIs. 104 | 105 | ## [1.1.2] - 2020-03-19 106 | 107 | **Fixed:** 108 | 109 | - API should not response `id` field. 110 | - Fixed request body template. 111 | 112 | ## [1.1.0] - 2020-03-14 113 | 114 | **Changed:** 115 | 116 | - Support kind for group. 117 | - Support pagination for List API. 118 | - Improve SQL schemas. 119 | - Improve code. 120 | -------------------------------------------------------------------------------- /src/schema/user.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | // schema 模块不要引入官方库以外的其它模块或内部模块 4 | import ( 5 | "encoding/json" 6 | "time" 7 | ) 8 | 9 | // TableUser is a table name in db. 10 | const TableUser = "urbs_user" 11 | 12 | // User 详见 ./sql/schema.sql table `urbs_user` 13 | // 记录用户外部唯一 ID,uid 和最近活跃时间 14 | // 缓存用户当前全部 label,根据 active_at 和 cache_label_expire 刷新 15 | // labels 格式:TODO 16 | type User struct { 17 | ID int64 `db:"id" json:"-" goqu:"skipinsert"` 18 | CreatedAt time.Time `db:"created_at" json:"createdAt" goqu:"skipinsert"` 19 | UID string `db:"uid" json:"uid"` // varchar(63),用户外部ID,表内唯一, 如 Teambition user id 20 | ActiveAt int64 `db:"active_at" json:"activeAt"` // 最近活跃时间戳,1970 以来的秒数,但不及时更新 21 | Labels string `db:"labels" json:"labels"` // varchar(8190),缓存用户当前被设置的 labels 22 | } 23 | 24 | // GetUsersUID 返回 users 数组的 uid 数组 25 | func GetUsersUID(users []User) []string { 26 | uids := make([]string, len(users)) 27 | for i, u := range users { 28 | uids[i] = u.UID 29 | } 30 | return uids 31 | } 32 | 33 | // TableName retuns table name 34 | func (User) TableName() string { 35 | return "urbs_user" 36 | } 37 | 38 | // MyLabelInfo ... 39 | type MyLabelInfo struct { 40 | ID int64 `db:"id"` 41 | CreatedAt time.Time `db:"created_at"` 42 | Name string `db:"name"` 43 | Channels string `db:"channels"` 44 | Clients string `db:"clients"` 45 | Product string `db:"product"` 46 | } 47 | 48 | // UserCache 用于在 User 数据上缓存数据 49 | type UserCache struct { 50 | ActiveAt int64 `json:"activeAt"` // 最近活跃时间戳,1970 以来的秒数,但不及时更新 51 | Labels []UserCacheLabel `json:"labels"` 52 | } 53 | 54 | // UserCacheLabel 用于在 User 数据上缓存 labels 55 | type UserCacheLabel struct { 56 | Label string `json:"l"` 57 | Clients []string `json:"cls,omitempty"` 58 | Channels []string `json:"chs,omitempty"` 59 | } 60 | 61 | // UserCacheLabelMap 用于在 User 数据上缓存 62 | type UserCacheLabelMap map[string]*UserCache 63 | 64 | // GetCache 从 user 上读取结构化的缓存数据 65 | func (u *User) GetCache(product string) *UserCache { 66 | userCache := &UserCache{} 67 | if u.Labels == "" { 68 | return userCache 69 | } 70 | data := u.GetCacheMap() 71 | for k, ucl := range data { 72 | if k == product { 73 | return ucl 74 | } 75 | } 76 | return userCache 77 | } 78 | 79 | // GetLabels 从 user 上读取结构化的 labels 数据 80 | func (u *User) GetLabels(product string) []UserCacheLabel { 81 | return u.GetCache(product).Labels 82 | } 83 | 84 | // GetCacheMap 从 user 上读取结构化的缓存数据 85 | func (u *User) GetCacheMap() UserCacheLabelMap { 86 | data := make(UserCacheLabelMap) 87 | if u.Labels == "" { 88 | return data 89 | } 90 | _ = json.Unmarshal([]byte(u.Labels), &data) 91 | return data 92 | } 93 | 94 | // PutCacheMap 把结构化的 labels 数据转成字符串设置在 user.Labels 上 95 | func (u *User) PutCacheMap(labels UserCacheLabelMap) error { 96 | data, err := json.Marshal(labels) 97 | if err == nil { 98 | u.Labels = string(data) 99 | } 100 | return err 101 | } 102 | -------------------------------------------------------------------------------- /src/api/label_v2_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/DavidCai1993/request" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/teambition/urbs-setting/src/dto" 10 | "github.com/teambition/urbs-setting/src/schema" 11 | "github.com/teambition/urbs-setting/src/tpl" 12 | ) 13 | 14 | func TestLabelAPIsV2(t *testing.T) { 15 | tt, cleanup := SetUpTestTools() 16 | defer cleanup() 17 | 18 | product, err := createProduct(tt) 19 | assert.Nil(t, err) 20 | 21 | t.Run(`POST "/v2/products/:product/labels/:label+:assign"`, func(t *testing.T) { 22 | label, err := createLabel(tt, product.Name) 23 | assert.Nil(t, err) 24 | 25 | users, err := createUsers(tt, 3) 26 | assert.Nil(t, err) 27 | 28 | group, err := createGroup(tt) 29 | assert.Nil(t, err) 30 | 31 | t.Run("should work", func(t *testing.T) { 32 | assert := assert.New(t) 33 | 34 | res, err := request.Post(fmt.Sprintf("%s/v2/products/%s/labels/%s:assign", tt.Host, product.Name, label.Name)). 35 | Set("Content-Type", "application/json"). 36 | Send(tpl.UsersGroupsBodyV2{ 37 | Users: schema.GetUsersUID(users[0:2]), 38 | Groups: []*tpl.GroupKindUID{ 39 | {UID: group.UID, Kind: dto.GroupOrgKind}, 40 | }, 41 | }). 42 | End() 43 | assert.Nil(err) 44 | assert.Equal(200, res.StatusCode) 45 | 46 | json := tpl.LabelReleaseInfoRes{} 47 | res.JSON(&json) 48 | assert.Equal(int64(1), json.Result.Release) 49 | assert.Equal(group.UID, json.Result.Groups[0]) 50 | 51 | var count int64 52 | _, err = tt.DB.ScanVal(&count, "select count(*) from `user_label` where `label_id` = ?", label.ID) 53 | assert.Nil(err) 54 | assert.Equal(int64(2), count) 55 | 56 | _, err = tt.DB.ScanVal(&count, "select count(*) from `group_label` where `label_id` = ?", label.ID) 57 | assert.Nil(err) 58 | assert.Equal(int64(1), count) 59 | }) 60 | 61 | t.Run("should work with duplicate data", func(t *testing.T) { 62 | assert := assert.New(t) 63 | 64 | uids := []string{users[0].UID, users[2].UID} 65 | res, err := request.Post(fmt.Sprintf("%s/v2/products/%s/labels/%s:assign", tt.Host, product.Name, label.Name)). 66 | Set("Content-Type", "application/json"). 67 | Send(tpl.UsersGroupsBody{ 68 | Users: uids, 69 | }). 70 | End() 71 | assert.Nil(err) 72 | assert.Equal(200, res.StatusCode) 73 | 74 | json := tpl.LabelReleaseInfoRes{} 75 | res.JSON(&json) 76 | assert.Equal(int64(2), json.Result.Release) 77 | assert.Equal(0, len(json.Result.Groups)) 78 | assert.True(tpl.StringSliceHas(json.Result.Users, users[0].UID)) 79 | assert.True(tpl.StringSliceHas(json.Result.Users, users[2].UID)) 80 | 81 | var count int64 82 | _, err = tt.DB.ScanVal(&count, "select count(*) from `user_label` where `label_id` = ?", label.ID) 83 | assert.Nil(err) 84 | assert.Equal(int64(3), count) 85 | 86 | _, err = tt.DB.ScanVal(&count, "select count(*) from `group_label` where `label_id` = ?", label.ID) 87 | assert.Nil(err) 88 | assert.Equal(int64(1), count) 89 | }) 90 | }) 91 | } 92 | -------------------------------------------------------------------------------- /sql/update_20200424.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `urbs_group` ADD COLUMN `status` bigint NOT NULL DEFAULT 0; 2 | ALTER TABLE `urbs_label` ADD COLUMN `rls` bigint NOT NULL DEFAULT 0; 3 | ALTER TABLE `urbs_setting` ADD COLUMN `rls` bigint NOT NULL DEFAULT 0; 4 | ALTER TABLE `user_label` ADD COLUMN `rls` bigint NOT NULL DEFAULT 0; 5 | ALTER TABLE `user_setting` ADD COLUMN `rls` bigint NOT NULL DEFAULT 0; 6 | ALTER TABLE `group_label` ADD COLUMN `rls` bigint NOT NULL DEFAULT 0; 7 | ALTER TABLE `group_setting` ADD COLUMN `rls` bigint NOT NULL DEFAULT 0; 8 | 9 | ALTER TABLE `user_label` ADD INDEX `idx_user_label_label_id` (`label_id`); 10 | ALTER TABLE `user_setting` ADD INDEX `idx_user_setting_setting_id` (`setting_id`); 11 | ALTER TABLE `group_label` ADD INDEX `idx_group_label_label_id` (`label_id`); 12 | ALTER TABLE `group_setting` ADD INDEX `idx_group_setting_setting_id` (`setting_id`); 13 | 14 | CREATE TABLE IF NOT EXISTS `urbs`.`label_rule` ( 15 | `id` bigint NOT NULL AUTO_INCREMENT, 16 | `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 17 | `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), 18 | `product_id` bigint NOT NULL, 19 | `label_id` bigint NOT NULL, 20 | `kind` varchar(63) NOT NULL, 21 | `rule` varchar(1022) NOT NULL DEFAULT '', 22 | `rls` bigint NOT NULL DEFAULT 0, 23 | PRIMARY KEY (`id`), 24 | UNIQUE KEY `uk_label_rule_label_id_kind` (`label_id`,`kind`), 25 | KEY `idx_label_rule_product_id` (`product_id`), 26 | KEY `idx_label_rule_label_id` (`label_id`) 27 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; 28 | 29 | CREATE TABLE IF NOT EXISTS `urbs`.`setting_rule` ( 30 | `id` bigint NOT NULL AUTO_INCREMENT, 31 | `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 32 | `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), 33 | `product_id` bigint NOT NULL, 34 | `setting_id` bigint NOT NULL, 35 | `kind` varchar(63) NOT NULL, 36 | `rule` varchar(1022) NOT NULL DEFAULT '', 37 | `value` varchar(255) NOT NULL DEFAULT '', 38 | `rls` bigint NOT NULL DEFAULT 0, 39 | PRIMARY KEY (`id`), 40 | UNIQUE KEY `uk_setting_rule_setting_id_kind` (`setting_id`,`kind`), 41 | KEY `idx_setting_rule_product_id` (`product_id`), 42 | KEY `idx_setting_rule_setting_id` (`setting_id`) 43 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; 44 | 45 | CREATE TABLE IF NOT EXISTS `urbs`.`urbs_statistic` ( 46 | `id` bigint NOT NULL AUTO_INCREMENT, 47 | `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 48 | `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), 49 | `name` varchar(127) NOT NULL, 50 | `value` varchar(8190) NOT NULL DEFAULT '', 51 | `status` bigint NOT NULL DEFAULT 0, 52 | PRIMARY KEY (`id`), 53 | UNIQUE KEY `uk_urbs_statistic_name` (`name`) 54 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; 55 | 56 | CREATE TABLE IF NOT EXISTS `urbs`.`urbs_lock` ( 57 | `id` bigint NOT NULL AUTO_INCREMENT, 58 | `expire_at` datetime(3) NOT NULL, 59 | `name` varchar(127) NOT NULL, 60 | PRIMARY KEY (`id`), 61 | UNIQUE KEY `uk_urbs_statistic_name` (`name`) 62 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; 63 | -------------------------------------------------------------------------------- /src/conf/config.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | otgo "github.com/open-trust/ot-go-lib" 8 | "github.com/teambition/gear" 9 | "github.com/teambition/urbs-setting/src/util" 10 | ) 11 | 12 | func init() { 13 | p := &Config 14 | util.ReadConfig(p) 15 | if err := p.Validate(); err != nil { 16 | panic(err) 17 | } 18 | p.GlobalCtx = gear.ContextWithSignal(context.Background()) 19 | } 20 | 21 | // Logger logger config 22 | type Logger struct { 23 | Level string `json:"level" yaml:"level"` 24 | } 25 | 26 | // SQL ... 27 | type SQL struct { 28 | Host string `json:"host" yaml:"host"` 29 | User string `json:"user" yaml:"user"` 30 | Password string `json:"password" yaml:"password"` 31 | Database string `json:"database" yaml:"database"` 32 | Parameters string `json:"parameters" yaml:"parameters"` 33 | MaxIdleConns int `json:"max_idle_conns" yaml:"max_idle_conns"` 34 | MaxOpenConns int `json:"max_open_conns" yaml:"max_open_conns"` 35 | } 36 | 37 | // OpenTrust ... 38 | type OpenTrust struct { 39 | OTID otgo.OTID `json:"otid" yaml:"otid"` 40 | LegacyOTID otgo.OTID `json:"legacy_otid" yaml:"legacy_otid"` 41 | PrivateKeys []string `json:"private_keys" yaml:"private_keys"` 42 | DomainPublicKeys []string `json:"domain_public_keys" yaml:"domain_public_keys"` 43 | } 44 | 45 | // ConfigTpl ... 46 | type ConfigTpl struct { 47 | GlobalCtx context.Context 48 | SrvAddr string `json:"addr" yaml:"addr"` 49 | CertFile string `json:"cert_file" yaml:"cert_file"` 50 | KeyFile string `json:"key_file" yaml:"key_file"` 51 | Logger Logger `json:"logger" yaml:"logger"` 52 | MySQL SQL `json:"mysql" yaml:"mysql"` 53 | MySQLRd SQL `json:"mysql_read" yaml:"mysql_read"` 54 | CacheLabelExpire string `json:"cache_label_expire" yaml:"cache_label_expire"` 55 | Channels []string `json:"channels" yaml:"channels"` 56 | Clients []string `json:"clients" yaml:"clients"` 57 | HIDKey string `json:"hid_key" yaml:"hid_key"` 58 | AuthKeys []string `json:"auth_keys" yaml:"auth_keys"` 59 | OpenTrust OpenTrust `json:"open_trust" yaml:"open_trust"` 60 | cacheLabelExpire int64 // seconds, default to 60 seconds 61 | cacheLabelDoubleExpire int64 // cacheLabelDoubleExpire * 2 62 | } 63 | 64 | // Validate 用于完成基本的配置验证和初始化工作。业务相关的配置验证建议放到相关代码中实现,如 mysql 的配置。 65 | func (c *ConfigTpl) Validate() error { 66 | du, err := time.ParseDuration(c.CacheLabelExpire) 67 | if err != nil { 68 | return err 69 | } 70 | if du < time.Minute { 71 | du = time.Minute 72 | } 73 | c.cacheLabelExpire = int64(du / time.Second) 74 | c.cacheLabelDoubleExpire = 2 * c.cacheLabelExpire 75 | return nil 76 | } 77 | 78 | // IsCacheLabelExpired 判断用户缓存的 labels 是否超过有效期 79 | func (c *ConfigTpl) IsCacheLabelExpired(now, activeAt int64) bool { 80 | return now-activeAt > c.cacheLabelExpire 81 | } 82 | 83 | // IsCacheLabelDoubleExpired 判断用户缓存的 labels 是否超过缓存时间的 2 倍 84 | func (c *ConfigTpl) IsCacheLabelDoubleExpired(now, activeAt int64) bool { 85 | return now-activeAt > c.cacheLabelDoubleExpire 86 | } 87 | 88 | // Config ... 89 | var Config ConfigTpl 90 | -------------------------------------------------------------------------------- /src/bll/product.go: -------------------------------------------------------------------------------- 1 | package bll 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/teambition/gear" 7 | "github.com/teambition/urbs-setting/src/model" 8 | "github.com/teambition/urbs-setting/src/schema" 9 | "github.com/teambition/urbs-setting/src/tpl" 10 | ) 11 | 12 | // Product ... 13 | type Product struct { 14 | ms *model.Models 15 | } 16 | 17 | // List 返回产品列表 18 | func (b *Product) List(ctx context.Context, pg tpl.Pagination) (*tpl.ProductsRes, error) { 19 | products, total, err := b.ms.Product.Find(ctx, pg) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | res := &tpl.ProductsRes{Result: products} 25 | res.TotalSize = total 26 | if len(res.Result) > pg.PageSize { 27 | res.NextPageToken = tpl.IDToPageToken(res.Result[pg.PageSize].ID) 28 | res.Result = res.Result[:pg.PageSize] 29 | } 30 | return res, nil 31 | } 32 | 33 | // Create 创建产品 34 | func (b *Product) Create(ctx context.Context, name, desc string) (*tpl.ProductRes, error) { 35 | product := &schema.Product{Name: name, Desc: desc} 36 | if err := b.ms.Product.Create(ctx, product); err != nil { 37 | return nil, err 38 | } 39 | res := &tpl.ProductRes{Result: *product} 40 | return res, nil 41 | } 42 | 43 | // Update ... 44 | func (b *Product) Update(ctx context.Context, productName string, body tpl.ProductUpdateBody) (*tpl.ProductRes, error) { 45 | productID, err := b.ms.Product.AcquireID(ctx, productName) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | product, err := b.ms.Product.Update(ctx, productID, body.ToMap()) 51 | if err != nil { 52 | return nil, err 53 | } 54 | return &tpl.ProductRes{Result: *product}, nil 55 | } 56 | 57 | // Offline 下线产品 58 | func (b *Product) Offline(ctx context.Context, productName string) (*tpl.BoolRes, error) { 59 | product, err := b.ms.Product.FindByName(ctx, productName, "id, `offline_at`, `deleted_at`") 60 | if err != nil { 61 | return nil, err 62 | } 63 | if product == nil { 64 | return nil, gear.ErrNotFound.WithMsgf("product %s not found", productName) 65 | } 66 | if product.DeletedAt != nil { 67 | return nil, gear.ErrNotFound.WithMsgf("product %s was deleted", productName) 68 | } 69 | 70 | res := &tpl.BoolRes{Result: false} 71 | if product.OfflineAt == nil { 72 | if err = b.ms.Product.Offline(ctx, product.ID); err != nil { 73 | return nil, err 74 | } 75 | res.Result = true 76 | } 77 | return res, nil 78 | } 79 | 80 | // Delete 逻辑删除产品 81 | func (b *Product) Delete(ctx context.Context, productName string) (*tpl.BoolRes, error) { 82 | product, err := b.ms.Product.FindByName(ctx, productName, "id, `offline_at`, `deleted_at`") 83 | 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | res := &tpl.BoolRes{Result: false} 89 | if product != nil { 90 | if product.OfflineAt == nil { 91 | return nil, gear.ErrConflict.WithMsgf("product %s is not offline", productName) 92 | } 93 | 94 | if product.DeletedAt == nil { 95 | if err = b.ms.Product.Delete(ctx, product.ID); err != nil { 96 | return nil, err 97 | } 98 | res.Result = true 99 | } 100 | } 101 | return res, nil 102 | } 103 | 104 | // Statistics 返回产品的统计数据 105 | func (b *Product) Statistics(ctx context.Context, productName string) (*tpl.ProductStatistics, error) { 106 | productID, err := b.ms.Product.AcquireID(ctx, productName) 107 | if err != nil { 108 | return nil, err 109 | } 110 | return b.ms.Product.Statistics(ctx, productID) 111 | } 112 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at admin@zensh.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /doc/paths_product.yaml: -------------------------------------------------------------------------------- 1 | # Product API 2 | /v1/products: 3 | get: 4 | tags: 5 | - Product 6 | summary: 读取产品列表,支持分页,按照创建时间倒序 7 | security: 8 | - HeaderAuthorizationJWT: {} 9 | parameters: 10 | - $ref: '#/components/parameters/HeaderAuthorization' 11 | - $ref: "#/components/parameters/QueryPageSize" 12 | - $ref: "#/components/parameters/QueryPageToken" 13 | - $ref: "#/components/parameters/QueryQ" 14 | responses: 15 | '200': 16 | $ref: '#/components/responses/ProductsRes' 17 | '401': 18 | $ref: '#/components/responses/ErrorResponse' 19 | post: 20 | tags: 21 | - Product 22 | summary: 添加产品,产品 name 必须唯一 23 | security: 24 | - HeaderAuthorizationJWT: {} 25 | parameters: 26 | - $ref: '#/components/parameters/HeaderAuthorization' 27 | requestBody: 28 | $ref: '#/components/requestBodies/NameDescBody' 29 | responses: 30 | '200': 31 | $ref: '#/components/responses/ProductRes' 32 | '401': 33 | $ref: '#/components/responses/ErrorResponse' 34 | '400': 35 | $ref: '#/components/responses/ErrorResponse' 36 | 37 | /v1/products/{product}: 38 | put: 39 | tags: 40 | - Product 41 | summary: 更新指定 product name 的产品 42 | security: 43 | - HeaderAuthorizationJWT: {} 44 | parameters: 45 | - $ref: '#/components/parameters/HeaderAuthorization' 46 | - $ref: "#/components/parameters/PathProduct" 47 | requestBody: 48 | $ref: '#/components/requestBodies/ProductUpdateBody' 49 | responses: 50 | '200': 51 | $ref: '#/components/responses/GroupRes' 52 | delete: 53 | tags: 54 | - Product 55 | summary: 删除指定 product name 的产品,产品必须下线后才能被删除 56 | security: 57 | - HeaderAuthorizationJWT: {} 58 | parameters: 59 | - $ref: '#/components/parameters/HeaderAuthorization' 60 | - $ref: "#/components/parameters/PathProduct" 61 | responses: 62 | '200': 63 | $ref: '#/components/responses/BoolRes' 64 | 65 | /v1/products/{product}:offline: 66 | put: 67 | tags: 68 | - Product 69 | summary: 将指定 product name 的产品下线,此操作会将产品名下的所有功能模块和配置项都下线,所有设置给用户或群组的对应配置项和环境标签也会被移除! 70 | security: 71 | - HeaderAuthorizationJWT: {} 72 | parameters: 73 | - $ref: '#/components/parameters/HeaderAuthorization' 74 | - $ref: "#/components/parameters/PathProduct" 75 | responses: 76 | '200': 77 | $ref: '#/components/responses/BoolRes' 78 | 79 | /v1/products/{product}/statistics: 80 | put: 81 | tags: 82 | - Product 83 | summary: 将指定 product name 的产品的统计数据 84 | security: 85 | - HeaderAuthorizationJWT: {} 86 | parameters: 87 | - $ref: '#/components/parameters/HeaderAuthorization' 88 | - $ref: "#/components/parameters/PathProduct" 89 | responses: 90 | '200': 91 | $ref: '#/components/responses/ProductStatisticsRes' 92 | /v1/products/:product/users/rules:apply: 93 | post: 94 | tags: 95 | - Product 96 | summary: 触发用户应用在指定产品下的规则 97 | description: 触发用户应用在指定产品下的规则,同步应用 setting 和 label 规则,由于 label 在网关层有一定时间缓存,会存在用户标签不能及时生效的情况。 98 | security: 99 | - HeaderAuthorizationJWT: {} 100 | parameters: 101 | - $ref: '#/components/parameters/HeaderAuthorization' 102 | - $ref: "#/components/parameters/PathProduct" 103 | requestBody: 104 | $ref: '#/components/requestBodies/ApplyRulesBody' 105 | responses: 106 | '200': 107 | $ref: '#/components/responses/BoolRes' -------------------------------------------------------------------------------- /src/api/user.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/teambition/gear" 5 | "github.com/teambition/urbs-setting/src/bll" 6 | "github.com/teambition/urbs-setting/src/tpl" 7 | ) 8 | 9 | // User .. 10 | type User struct { 11 | blls *bll.Blls 12 | } 13 | 14 | // List .. 15 | func (a *User) List(ctx *gear.Context) error { 16 | req := tpl.Pagination{} 17 | if err := ctx.ParseURL(&req); err != nil { 18 | return err 19 | } 20 | 21 | res, err := a.blls.User.List(ctx, req) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | return ctx.OkJSON(res) 27 | } 28 | 29 | // ListCachedLabels 返回执行 user 在 product 下所有 labels,按照 label 指派时间反序 30 | func (a *User) ListCachedLabels(ctx *gear.Context) error { 31 | req := tpl.UIDProductURL{} 32 | if err := ctx.ParseURL(&req); err != nil { 33 | return err 34 | } 35 | 36 | res := a.blls.User.ListCachedLabels(ctx, req.UID, req.Product) 37 | return ctx.OkJSON(res) 38 | } 39 | 40 | // RefreshCachedLabels 强制更新 user 的 labels 缓存 41 | func (a *User) RefreshCachedLabels(ctx *gear.Context) error { 42 | req := tpl.UIDAndProductURL{} 43 | if err := ctx.ParseURL(&req); err != nil { 44 | return err 45 | } 46 | 47 | user, err := a.blls.User.RefreshCachedLabels(ctx, req.Product, req.UID) 48 | if err != nil { 49 | return err 50 | } 51 | res := tpl.UserRes{Result: *user} 52 | return ctx.OkJSON(res) 53 | } 54 | 55 | // ListLabels 返回 user 的 labels,按照 label 指派时间正序,支持分页 56 | func (a *User) ListLabels(ctx *gear.Context) error { 57 | req := tpl.UIDPaginationURL{} 58 | if err := ctx.ParseURL(&req); err != nil { 59 | return err 60 | } 61 | 62 | res, err := a.blls.User.ListLabels(ctx, req.UID, req.Pagination) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | return ctx.OkJSON(res) 68 | } 69 | 70 | // ListSettings 返回 user 的 settings,按照 setting 设置时间正序,支持分页 71 | func (a *User) ListSettings(ctx *gear.Context) error { 72 | req := tpl.MySettingsQueryURL{} 73 | if err := ctx.ParseURL(&req); err != nil { 74 | return err 75 | } 76 | 77 | res, err := a.blls.User.ListSettings(ctx, req) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | return ctx.OkJSON(res) 83 | } 84 | 85 | // ListSettingsUnionAll 返回 user 的 settings,按照 setting 设置时间反序,支持分页 86 | // 包含了 user 从属的 group 的 settings 87 | func (a *User) ListSettingsUnionAll(ctx *gear.Context) error { 88 | req := tpl.MySettingsQueryURL{} 89 | if err := ctx.ParseURL(&req); err != nil { 90 | return err 91 | } 92 | 93 | if req.Product == "" { 94 | return gear.ErrBadRequest.WithMsgf("product required") 95 | } 96 | 97 | res, err := a.blls.User.ListSettingsUnionAll(ctx, req) 98 | if err != nil { 99 | return err 100 | } 101 | 102 | return ctx.OkJSON(res) 103 | } 104 | 105 | // CheckExists .. 106 | func (a *User) CheckExists(ctx *gear.Context) error { 107 | req := tpl.UIDURL{} 108 | if err := ctx.ParseURL(&req); err != nil { 109 | return err 110 | } 111 | 112 | res := tpl.BoolRes{Result: a.blls.User.CheckExists(ctx, req.UID)} 113 | return ctx.OkJSON(res) 114 | } 115 | 116 | // BatchAdd .. 117 | func (a *User) BatchAdd(ctx *gear.Context) error { 118 | req := tpl.UsersBody{} 119 | if err := ctx.ParseBody(&req); err != nil { 120 | return err 121 | } 122 | 123 | if err := a.blls.User.BatchAdd(ctx, req.Users); err != nil { 124 | return err 125 | } 126 | 127 | return ctx.OkJSON(tpl.BoolRes{Result: true}) 128 | } 129 | 130 | // ApplyRules .. 131 | func (a *User) ApplyRules(ctx *gear.Context) error { 132 | req := &tpl.ProductURL{} 133 | if err := ctx.ParseURL(req); err != nil { 134 | return err 135 | } 136 | body := &tpl.ApplyRulesBody{} 137 | if err := ctx.ParseBody(body); err != nil { 138 | return err 139 | } 140 | err := a.blls.User.ApplyRules(ctx, req.Product, body) 141 | if err != nil { 142 | return err 143 | } 144 | return ctx.OkJSON(tpl.BoolRes{Result: true}) 145 | } 146 | -------------------------------------------------------------------------------- /src/model/module.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/doug-martin/goqu/v9" 8 | "github.com/teambition/gear" 9 | "github.com/teambition/urbs-setting/src/schema" 10 | "github.com/teambition/urbs-setting/src/tpl" 11 | "github.com/teambition/urbs-setting/src/util" 12 | ) 13 | 14 | // Module ... 15 | type Module struct { 16 | *Model 17 | } 18 | 19 | // FindByName 根据 productID 和 name 返回 module 数据 20 | func (m *Module) FindByName(ctx context.Context, productID int64, name, selectStr string) (*schema.Module, error) { 21 | module := &schema.Module{} 22 | ok, err := m.findOneByCols(ctx, schema.TableModule, goqu.Ex{"product_id": productID, "name": name}, selectStr, module) 23 | if err != nil { 24 | return nil, err 25 | } 26 | if !ok { 27 | return nil, nil 28 | } 29 | 30 | return module, nil 31 | } 32 | 33 | // Acquire ... 34 | func (m *Module) Acquire(ctx context.Context, productID int64, moduleName string) (*schema.Module, error) { 35 | module, err := m.FindByName(ctx, productID, moduleName, "") 36 | if err != nil { 37 | return nil, err 38 | } 39 | if module == nil { 40 | return nil, gear.ErrNotFound.WithMsgf("module %s not found", moduleName) 41 | } 42 | if module.OfflineAt != nil { 43 | return nil, gear.ErrNotFound.WithMsgf("module %s was offline", moduleName) 44 | } 45 | return module, nil 46 | } 47 | 48 | // AcquireID ... 49 | func (m *Module) AcquireID(ctx context.Context, productID int64, moduleName string) (int64, error) { 50 | module, err := m.FindByName(ctx, productID, moduleName, "id, offline_at") 51 | if err != nil { 52 | return 0, err 53 | } 54 | if module == nil { 55 | return 0, gear.ErrNotFound.WithMsgf("module %s not found", moduleName) 56 | } 57 | if module.OfflineAt != nil { 58 | return 0, gear.ErrNotFound.WithMsgf("module %s was offline", moduleName) 59 | } 60 | return module.ID, nil 61 | } 62 | 63 | // Find 根据条件查找 modules 64 | func (m *Module) Find(ctx context.Context, productID int64, pg tpl.Pagination) ([]schema.Module, int, error) { 65 | modules := make([]schema.Module, 0) 66 | cursor := pg.TokenToID() 67 | sdc := m.RdDB.Select(). 68 | From(goqu.T(schema.TableModule)). 69 | Where( 70 | goqu.C("product_id").Eq(productID), 71 | goqu.C("offline_at").IsNull()) 72 | 73 | sd := m.RdDB.Select(). 74 | From(goqu.T(schema.TableModule)). 75 | Where( 76 | goqu.C("id").Lte(cursor), 77 | goqu.C("product_id").Eq(productID), 78 | goqu.C("offline_at").IsNull()) 79 | 80 | if pg.Q != "" { 81 | sdc = sdc.Where(goqu.C("name").ILike(pg.Q)) 82 | sd = sd.Where(goqu.C("name").ILike(pg.Q)) 83 | } 84 | 85 | sd = sd.Order(goqu.C("id").Desc()).Limit(uint(pg.PageSize + 1)) 86 | 87 | total, err := sdc.CountContext(ctx) 88 | if err != nil { 89 | return nil, 0, err 90 | } 91 | 92 | if err = sd.Executor().ScanStructsContext(ctx, &modules); err != nil { 93 | return nil, 0, err 94 | } 95 | 96 | return modules, int(total), nil 97 | } 98 | 99 | // Create ... 100 | func (m *Module) Create(ctx context.Context, module *schema.Module) error { 101 | rowsAffected, err := m.createOne(ctx, schema.TableModule, module) 102 | if rowsAffected > 0 { 103 | util.Go(5*time.Second, func(gctx context.Context) { 104 | m.tryIncreaseStatisticStatus(gctx, schema.ModulesTotalSize, 1) 105 | }) 106 | } 107 | return err 108 | } 109 | 110 | // Update 更新指定功能模块 111 | func (m *Module) Update(ctx context.Context, moduleID int64, changed map[string]interface{}) (*schema.Module, error) { 112 | module := &schema.Module{} 113 | if _, err := m.updateByID(ctx, schema.TableModule, moduleID, goqu.Record(changed)); err != nil { 114 | return nil, err 115 | } 116 | if err := m.findOneByID(ctx, schema.TableModule, moduleID, module); err != nil { 117 | return nil, err 118 | } 119 | return module, nil 120 | } 121 | 122 | // Offline 标记模块下线 123 | func (m *Module) Offline(ctx context.Context, moduleID int64) error { 124 | return m.offlineModules(ctx, goqu.Ex{"id": moduleID, "offline_at": nil}) 125 | } 126 | -------------------------------------------------------------------------------- /src/api/group.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/teambition/gear" 5 | "github.com/teambition/urbs-setting/src/bll" 6 | "github.com/teambition/urbs-setting/src/tpl" 7 | ) 8 | 9 | // Group .. 10 | type Group struct { 11 | blls *bll.Blls 12 | } 13 | 14 | // List .. 15 | func (a *Group) List(ctx *gear.Context) error { 16 | req := tpl.GroupsURL{} 17 | if err := ctx.ParseURL(&req); err != nil { 18 | return err 19 | } 20 | 21 | res, err := a.blls.Group.List(ctx, req.Kind, req.Pagination) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | return ctx.OkJSON(res) 27 | } 28 | 29 | // ListLabels .. 30 | func (a *Group) ListLabels(ctx *gear.Context) error { 31 | req := tpl.GroupPaginationURL{} 32 | if err := ctx.ParseURL(&req); err != nil { 33 | return err 34 | } 35 | 36 | res, err := a.blls.Group.ListLabels(ctx, req.Kind, req.UID, req.Pagination) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | return ctx.OkJSON(res) 42 | } 43 | 44 | // ListMembers .. 45 | func (a *Group) ListMembers(ctx *gear.Context) error { 46 | req := tpl.GroupPaginationURL{} 47 | if err := ctx.ParseURL(&req); err != nil { 48 | return err 49 | } 50 | 51 | res, err := a.blls.Group.ListMembers(ctx, req.Kind, req.UID, req.Pagination) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | return ctx.OkJSON(res) 57 | } 58 | 59 | // ListSettings .. 60 | func (a *Group) ListSettings(ctx *gear.Context) error { 61 | req := tpl.MySettingsQueryURL{} 62 | if err := ctx.ParseURL(&req); err != nil { 63 | return err 64 | } 65 | 66 | res, err := a.blls.Group.ListSettings(ctx, req) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | return ctx.OkJSON(res) 72 | } 73 | 74 | // CheckExists .. 75 | func (a *Group) CheckExists(ctx *gear.Context) error { 76 | req := tpl.GroupURL{} 77 | if err := ctx.ParseURL(&req); err != nil { 78 | return err 79 | } 80 | 81 | res := tpl.BoolRes{Result: a.blls.Group.CheckExists(ctx, req.Kind, req.UID)} 82 | return ctx.OkJSON(res) 83 | } 84 | 85 | // BatchAdd .. 86 | func (a *Group) BatchAdd(ctx *gear.Context) error { 87 | req := tpl.GroupsBody{} 88 | if err := ctx.ParseBody(&req); err != nil { 89 | return err 90 | } 91 | 92 | if err := a.blls.Group.BatchAdd(ctx, req.Groups); err != nil { 93 | return err 94 | } 95 | 96 | return ctx.OkJSON(tpl.BoolRes{Result: true}) 97 | } 98 | 99 | // Update .. 100 | func (a *Group) Update(ctx *gear.Context) error { 101 | req := tpl.GroupURL{} 102 | if err := ctx.ParseURL(&req); err != nil { 103 | return err 104 | } 105 | 106 | body := tpl.GroupUpdateBody{} 107 | if err := ctx.ParseBody(&body); err != nil { 108 | return err 109 | } 110 | 111 | res, err := a.blls.Group.Update(ctx, req.Kind, req.UID, body) 112 | if err != nil { 113 | return err 114 | } 115 | return ctx.OkJSON(res) 116 | } 117 | 118 | // Delete .. 119 | func (a *Group) Delete(ctx *gear.Context) error { 120 | req := tpl.GroupURL{} 121 | if err := ctx.ParseURL(&req); err != nil { 122 | return err 123 | } 124 | if err := a.blls.Group.Delete(ctx, req.Kind, req.UID); err != nil { 125 | return err 126 | } 127 | 128 | return ctx.OkJSON(tpl.BoolRes{Result: true}) 129 | } 130 | 131 | // BatchAddMembers .. 132 | func (a *Group) BatchAddMembers(ctx *gear.Context) error { 133 | req := tpl.GroupURL{} 134 | if err := ctx.ParseURL(&req); err != nil { 135 | return err 136 | } 137 | 138 | body := tpl.UsersBody{} 139 | if err := ctx.ParseBody(&body); err != nil { 140 | return err 141 | } 142 | 143 | if err := a.blls.Group.BatchAddMembers(ctx, req.Kind, req.UID, body.Users); err != nil { 144 | return err 145 | } 146 | 147 | return ctx.OkJSON(tpl.BoolRes{Result: true}) 148 | } 149 | 150 | // RemoveMembers .. 151 | func (a *Group) RemoveMembers(ctx *gear.Context) error { 152 | req := tpl.GroupMembersURL{} 153 | if err := ctx.ParseURL(&req); err != nil { 154 | return err 155 | } 156 | if err := a.blls.Group.RemoveMembers(ctx, req.Kind, req.UID, req.User, req.SyncLt); err != nil { 157 | return err 158 | } 159 | 160 | return ctx.OkJSON(tpl.BoolRes{Result: true}) 161 | } 162 | -------------------------------------------------------------------------------- /src/api/setting_v2_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/DavidCai1993/request" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/teambition/urbs-setting/src/dto" 10 | "github.com/teambition/urbs-setting/src/schema" 11 | "github.com/teambition/urbs-setting/src/tpl" 12 | ) 13 | 14 | func TestSettingAPIsV2(t *testing.T) { 15 | tt, cleanup := SetUpTestTools() 16 | defer cleanup() 17 | 18 | product, err := createProduct(tt) 19 | assert.Nil(t, err) 20 | 21 | t.Run(`POST "/v2/products/:product/modules/:module/settings/:setting+:assign"`, func(t *testing.T) { 22 | module, err := createModule(tt, product.Name) 23 | assert.Nil(t, err) 24 | 25 | setting, err := createSetting(tt, product.Name, module.Name, "a", "b") 26 | assert.Nil(t, err) 27 | 28 | users, err := createUsers(tt, 3) 29 | assert.Nil(t, err) 30 | 31 | group, err := createGroup(tt) 32 | assert.Nil(t, err) 33 | 34 | t.Run("should work", func(t *testing.T) { 35 | assert := assert.New(t) 36 | 37 | res, err := request.Post(fmt.Sprintf("%s/v2/products/%s/modules/%s/settings/%s:assign", tt.Host, product.Name, module.Name, setting.Name)). 38 | Set("Content-Type", "application/json"). 39 | Send(tpl.UsersGroupsBodyV2{ 40 | Users: schema.GetUsersUID(users[0:2]), 41 | Groups: []*tpl.GroupKindUID{{UID: group.UID, Kind: dto.GroupOrgKind}}, 42 | Value: "a", 43 | }). 44 | End() 45 | assert.Nil(err) 46 | assert.Equal(200, res.StatusCode) 47 | 48 | json := tpl.SettingReleaseInfoRes{} 49 | res.JSON(&json) 50 | assert.Equal(int64(1), json.Result.Release) 51 | assert.Equal("a", json.Result.Value) 52 | assert.Equal(group.UID, json.Result.Groups[0]) 53 | 54 | var count int64 55 | _, err = tt.DB.ScanVal(&count, "select count(*) from `user_setting` where `setting_id` = ?", setting.ID) 56 | assert.Nil(err) 57 | assert.Equal(int64(2), count) 58 | 59 | _, err = tt.DB.ScanVal(&count, "select count(*) from `group_setting` where `setting_id` = ?", setting.ID) 60 | assert.Nil(err) 61 | assert.Equal(int64(1), count) 62 | 63 | res, err = request.Get(fmt.Sprintf("%s/v1/users/%s/settings:unionAll?product=%s", tt.Host, users[0].UID, product.Name)). 64 | End() 65 | assert.Nil(err) 66 | assert.Equal(200, res.StatusCode) 67 | 68 | json2 := tpl.MySettingsRes{} 69 | _, err = res.JSON(&json2) 70 | 71 | assert.Equal(1, len(json2.Result)) 72 | 73 | data := json2.Result[0] 74 | assert.Equal("a", data.Value) 75 | assert.Equal("", data.LastValue) 76 | }) 77 | 78 | t.Run("should work with duplicate data", func(t *testing.T) { 79 | assert := assert.New(t) 80 | 81 | uids := []string{users[0].UID, users[2].UID} 82 | res, err := request.Post(fmt.Sprintf("%s/v2/products/%s/modules/%s/settings/%s:assign", tt.Host, product.Name, module.Name, setting.Name)). 83 | Set("Content-Type", "application/json"). 84 | Send(tpl.UsersGroupsBodyV2{ 85 | Users: uids, 86 | Value: "b", 87 | }). 88 | End() 89 | assert.Nil(err) 90 | assert.Equal(200, res.StatusCode) 91 | 92 | json := tpl.SettingReleaseInfoRes{} 93 | res.JSON(&json) 94 | result := json.Result 95 | assert.Equal(int64(2), result.Release) 96 | assert.Equal("b", result.Value) 97 | assert.Equal(0, len(result.Groups)) 98 | assert.True(tpl.StringSliceHas(result.Users, users[0].UID)) 99 | assert.True(tpl.StringSliceHas(result.Users, users[2].UID)) 100 | 101 | var count int64 102 | _, err = tt.DB.ScanVal(&count, "select count(*) from `user_setting` where `setting_id` = ?", setting.ID) 103 | assert.Nil(err) 104 | assert.Equal(int64(3), count) 105 | 106 | _, err = tt.DB.ScanVal(&count, "select count(*) from `group_setting` where `setting_id` = ?", setting.ID) 107 | assert.Nil(err) 108 | assert.Equal(int64(1), count) 109 | 110 | res, err = request.Get(fmt.Sprintf("%s/v1/users/%s/settings:unionAll?product=%s", tt.Host, users[0].UID, product.Name)). 111 | End() 112 | assert.Nil(err) 113 | assert.Equal(200, res.StatusCode) 114 | 115 | json2 := tpl.MySettingsRes{} 116 | _, err = res.JSON(&json2) 117 | 118 | assert.Equal(1, len(json2.Result)) 119 | 120 | data := json2.Result[0] 121 | assert.Equal("b", data.Value) 122 | assert.Equal("a", data.LastValue) 123 | }) 124 | }) 125 | } 126 | -------------------------------------------------------------------------------- /sql/update_20200314.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `urbs_user` MODIFY COLUMN `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3); 2 | ALTER TABLE `urbs_user` MODIFY COLUMN `uid` varchar(63) NOT NULL COLLATE utf8mb4_bin; 3 | ALTER TABLE `urbs_user` MODIFY COLUMN `labels` varchar(8190) NOT NULL COLLATE utf8mb4_bin DEFAULT ''; 4 | 5 | ALTER TABLE `urbs_group` MODIFY COLUMN `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3); 6 | ALTER TABLE `urbs_group` MODIFY COLUMN `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3); 7 | ALTER TABLE `urbs_group` MODIFY COLUMN `uid` varchar(63) NOT NULL COLLATE utf8mb4_bin; 8 | ALTER TABLE `urbs_group` MODIFY COLUMN `description` varchar(1022) NOT NULL COLLATE utf8mb4_bin DEFAULT ''; 9 | ALTER TABLE `urbs_group` ADD COLUMN `kind` varchar(63) NOT NULL COLLATE utf8mb4_bin DEFAULT ''; 10 | ALTER TABLE `urbs_group` DROP INDEX `idx_group_sync_at`; 11 | ALTER TABLE `urbs_group` ADD INDEX `idx_group_kind` (`kind`); 12 | 13 | ALTER TABLE `urbs_product` MODIFY COLUMN `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3); 14 | ALTER TABLE `urbs_product` MODIFY COLUMN `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3); 15 | ALTER TABLE `urbs_product` MODIFY COLUMN `deleted_at` datetime(3) DEFAULT NULL; 16 | ALTER TABLE `urbs_product` MODIFY COLUMN `offline_at` datetime(3) DEFAULT NULL; 17 | ALTER TABLE `urbs_product` MODIFY COLUMN `name` varchar(63) NOT NULL COLLATE utf8mb4_bin; 18 | ALTER TABLE `urbs_product` MODIFY COLUMN `description` varchar(1022) NOT NULL COLLATE utf8mb4_bin DEFAULT ''; 19 | 20 | ALTER TABLE `urbs_label` MODIFY COLUMN `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3); 21 | ALTER TABLE `urbs_label` MODIFY COLUMN `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3); 22 | ALTER TABLE `urbs_label` MODIFY COLUMN `offline_at` datetime(3) DEFAULT NULL; 23 | ALTER TABLE `urbs_label` MODIFY COLUMN `name` varchar(63) NOT NULL COLLATE utf8mb4_bin; 24 | ALTER TABLE `urbs_label` MODIFY COLUMN `description` varchar(1022) NOT NULL COLLATE utf8mb4_bin DEFAULT ''; 25 | ALTER TABLE `urbs_label` MODIFY COLUMN `channels` varchar(255) NOT NULL COLLATE utf8mb4_bin DEFAULT ''; 26 | ALTER TABLE `urbs_label` MODIFY COLUMN `clients` varchar(255) NOT NULL COLLATE utf8mb4_bin DEFAULT ''; 27 | 28 | ALTER TABLE `urbs_module` MODIFY COLUMN `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3); 29 | ALTER TABLE `urbs_module` MODIFY COLUMN `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3); 30 | ALTER TABLE `urbs_module` MODIFY COLUMN `offline_at` datetime(3) DEFAULT NULL; 31 | ALTER TABLE `urbs_module` MODIFY COLUMN `name` varchar(63) NOT NULL COLLATE utf8mb4_bin; 32 | ALTER TABLE `urbs_module` MODIFY COLUMN `description` varchar(1022) NOT NULL COLLATE utf8mb4_bin DEFAULT ''; 33 | 34 | ALTER TABLE `urbs_setting` MODIFY COLUMN `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3); 35 | ALTER TABLE `urbs_setting` MODIFY COLUMN `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3); 36 | ALTER TABLE `urbs_setting` MODIFY COLUMN `offline_at` datetime(3) DEFAULT NULL; 37 | ALTER TABLE `urbs_setting` MODIFY COLUMN `name` varchar(63) NOT NULL COLLATE utf8mb4_bin; 38 | ALTER TABLE `urbs_setting` MODIFY COLUMN `description` varchar(1022) NOT NULL COLLATE utf8mb4_bin DEFAULT ''; 39 | ALTER TABLE `urbs_setting` MODIFY COLUMN `channels` varchar(255) NOT NULL COLLATE utf8mb4_bin DEFAULT ''; 40 | ALTER TABLE `urbs_setting` MODIFY COLUMN `clients` varchar(255) NOT NULL COLLATE utf8mb4_bin DEFAULT ''; 41 | ALTER TABLE `urbs_setting` MODIFY COLUMN `vals` varchar(1022) NOT NULL COLLATE utf8mb4_bin DEFAULT ''; 42 | 43 | ALTER TABLE `user_group` MODIFY COLUMN `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3); 44 | ALTER TABLE `user_group` DROP INDEX `idx_user_group_sync_at`; 45 | ALTER TABLE `user_group` ADD INDEX `idx_user_group_group_id` (`group_id`); 46 | 47 | ALTER TABLE `user_label` MODIFY COLUMN `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3); 48 | 49 | ALTER TABLE `user_setting` MODIFY COLUMN `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3); 50 | ALTER TABLE `user_setting` MODIFY COLUMN `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3); 51 | ALTER TABLE `user_setting` MODIFY COLUMN `value` varchar(255) NOT NULL COLLATE utf8mb4_bin DEFAULT ''; 52 | ALTER TABLE `user_setting` MODIFY COLUMN `last_value` varchar(255) NOT NULL COLLATE utf8mb4_bin DEFAULT ''; 53 | 54 | ALTER TABLE `group_label` MODIFY COLUMN `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3); 55 | 56 | ALTER TABLE `group_setting` MODIFY COLUMN `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3); 57 | ALTER TABLE `group_setting` MODIFY COLUMN `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3); 58 | ALTER TABLE `group_setting` MODIFY COLUMN `value` varchar(255) NOT NULL COLLATE utf8mb4_bin DEFAULT ''; 59 | ALTER TABLE `group_setting` MODIFY COLUMN `last_value` varchar(255) NOT NULL COLLATE utf8mb4_bin DEFAULT ''; 60 | -------------------------------------------------------------------------------- /doc/paths_user.yaml: -------------------------------------------------------------------------------- 1 | # User API 2 | /users/{uid}/labels:cache: 3 | get: 4 | tags: 5 | - User 6 | summary: 该接口为灰度网关提供用户的灰度信息,用于服务端灰度。获取指定 uid 用户在指定 product 产品下的所有(未分页,最多 400 条)环境标签,包括从 group 群组继承的环境标签,按照 label 指派时间反序。网关只会取匹配 client 和 channel 的第一条。标签列表不是实时数据,会被服务缓存,缓存时间在 config.cache_label_expire 配置,默认为 1 分钟,建议生产配置为 5 分钟。当 uid 对应用户不存在或 product 对应产品不存在时,该接口会返回空环境标签列表。当 uid 对应的用户不存在但以 `anon-` 开头时则为匿名用户,百分比发布规则对匿名用户生效。 7 | parameters: 8 | - $ref: "#/components/parameters/PathUID" 9 | - $ref: "#/components/parameters/QueryProduct" 10 | responses: 11 | '200': 12 | $ref: "#/components/responses/CacheLabelsInfo" 13 | 14 | /v1/users: 15 | get: 16 | tags: 17 | - User 18 | summary: 获取用户列表,支持分页,按照标签指派时间倒序 19 | security: 20 | - HeaderAuthorizationJWT: {} 21 | parameters: 22 | - $ref: '#/components/parameters/HeaderAuthorization' 23 | - $ref: "#/components/parameters/QueryPageSize" 24 | - $ref: "#/components/parameters/QueryPageToken" 25 | - $ref: "#/components/parameters/QueryQ" 26 | responses: 27 | '200': 28 | $ref: '#/components/responses/UsersRes' 29 | 30 | /v1/users/{uid}/settings:unionAll: 31 | get: 32 | tags: 33 | - User 34 | summary: 该接口为客户端提供用户的产品功能模块配置项信息,用于客户端功能灰度。获取指定 uid 用户在指定 product 产品下的功能模块配置项信息列表,包括从 group 群组继承的配置项信息列表,按照 setting 值更新时间 updatedAt 反序。该 API 支持分页,默认获取最新更新的前 10 条,分页参数 nextPageToken 为更新时间 updatedAt 值(进行了 encodeURI 转义)。如果客户端本地缓存了 setting 列表,可以判断 nextPageToken 的值,如果 **为空** 或者其值小于本地缓存的最大 updatedAt 值,就不用读取下一页了。该 API 还支持 channel 和 client 参数,让客户端只读取匹配 client 和 channel 的 setting 列表。当 uid 对应用户不存在时,该接口会返回空配置项列表。当 uid 对应的用户不存在但以 `anon-` 开头时则为匿名用户,百分比发布规则对匿名用户生效。 35 | security: 36 | - HeaderAuthorizationJWT: {} 37 | parameters: 38 | - $ref: '#/components/parameters/HeaderAuthorization' 39 | - $ref: "#/components/parameters/PathUID" 40 | - $ref: "#/components/parameters/QueryProduct" 41 | - $ref: "#/components/parameters/QueryModule" 42 | - $ref: "#/components/parameters/QuerySetting" 43 | - $ref: "#/components/parameters/QueryChannel" 44 | - $ref: "#/components/parameters/QueryClient" 45 | - $ref: "#/components/parameters/QueryPageSize" 46 | - $ref: "#/components/parameters/QueryPageToken" 47 | - $ref: "#/components/parameters/QueryQ" 48 | responses: 49 | '200': 50 | $ref: "#/components/responses/MySettingsRes" 51 | 52 | /v1/users/{uid}/labels: 53 | get: 54 | tags: 55 | - User 56 | summary: 获取指定 uid 用户环境标签列表,不包括从群组继承的环境标签,支持分页,按照标签指派时间倒序 57 | security: 58 | - HeaderAuthorizationJWT: {} 59 | parameters: 60 | - $ref: '#/components/parameters/HeaderAuthorization' 61 | - $ref: "#/components/parameters/PathUID" 62 | - $ref: "#/components/parameters/QueryPageSize" 63 | - $ref: "#/components/parameters/QueryPageToken" 64 | - $ref: "#/components/parameters/QueryQ" 65 | responses: 66 | '200': 67 | $ref: '#/components/responses/MyLabelsRes' 68 | 69 | /v1/users/{uid}/labels:cache: 70 | put: 71 | tags: 72 | - User 73 | summary: 强制刷新指定用户的环境标签列表缓存 74 | security: 75 | - HeaderAuthorizationJWT: {} 76 | parameters: 77 | - $ref: '#/components/parameters/HeaderAuthorization' 78 | - $ref: "#/components/parameters/PathUID" 79 | - $ref: "#/components/parameters/QueryProduct" 80 | responses: 81 | '200': 82 | $ref: '#/components/responses/UserRes' 83 | 84 | /v1/users/{uid}/settings: 85 | get: 86 | tags: 87 | - User 88 | summary: 获取指定 uid 用户在指定产品线下的功能模块配置项列表,不包括从群组继承的配置项,支持分页,按照配置项指派时间倒序 89 | security: 90 | - HeaderAuthorizationJWT: {} 91 | parameters: 92 | - $ref: '#/components/parameters/HeaderAuthorization' 93 | - $ref: "#/components/parameters/PathUID" 94 | - $ref: "#/components/parameters/QueryProduct" 95 | - $ref: "#/components/parameters/QueryModule" 96 | - $ref: "#/components/parameters/QuerySetting" 97 | - $ref: "#/components/parameters/QueryChannel" 98 | - $ref: "#/components/parameters/QueryClient" 99 | - $ref: "#/components/parameters/QueryPageSize" 100 | - $ref: "#/components/parameters/QueryPageToken" 101 | - $ref: "#/components/parameters/QueryQ" 102 | responses: 103 | '200': 104 | $ref: '#/components/responses/MySettingsRes' 105 | 106 | /v1/users/{uid}/exists: 107 | get: 108 | tags: 109 | - User 110 | summary: 判断指定 uid 用户是否存在 111 | security: 112 | - HeaderAuthorizationJWT: {} 113 | parameters: 114 | - $ref: '#/components/parameters/HeaderAuthorization' 115 | - $ref: "#/components/parameters/PathUID" 116 | responses: 117 | '200': 118 | $ref: '#/components/responses/BoolRes' 119 | 120 | /v1/users:batch: 121 | post: 122 | tags: 123 | - User 124 | summary: 批量添加用户,忽略已存在的用户 125 | security: 126 | - HeaderAuthorizationJWT: {} 127 | parameters: 128 | - $ref: '#/components/parameters/HeaderAuthorization' 129 | requestBody: 130 | $ref: '#/components/requestBodies/UsersBody' 131 | responses: 132 | '200': 133 | $ref: '#/components/responses/BoolRes' 134 | -------------------------------------------------------------------------------- /src/bll/group.go: -------------------------------------------------------------------------------- 1 | package bll 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/teambition/urbs-setting/src/model" 7 | "github.com/teambition/urbs-setting/src/tpl" 8 | ) 9 | 10 | // Group ... 11 | type Group struct { 12 | ms *model.Models 13 | } 14 | 15 | // List 返回群组列表 16 | func (b *Group) List(ctx context.Context, kind string, pg tpl.Pagination) (*tpl.GroupsRes, error) { 17 | groups, total, err := b.ms.Group.Find(context.WithValue(ctx, model.ReadDB, true), kind, pg) 18 | if err != nil { 19 | return nil, err 20 | } 21 | res := &tpl.GroupsRes{Result: groups} 22 | res.TotalSize = total 23 | if len(res.Result) > pg.PageSize { 24 | res.NextPageToken = tpl.IDToPageToken(res.Result[pg.PageSize].ID) 25 | res.Result = res.Result[:pg.PageSize] 26 | } 27 | return res, nil 28 | } 29 | 30 | // ListLabels ... 31 | func (b *Group) ListLabels(ctx context.Context, kind, uid string, pg tpl.Pagination) (*tpl.MyLabelsRes, error) { 32 | group, err := b.ms.Group.Acquire(context.WithValue(ctx, model.ReadDB, true), kind, uid) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | labels, total, err := b.ms.Group.FindLabels(ctx, group.ID, pg) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | res := &tpl.MyLabelsRes{Result: labels} 43 | res.TotalSize = total 44 | if len(res.Result) > pg.PageSize { 45 | res.NextPageToken = tpl.IDToPageToken(res.Result[pg.PageSize].ID) 46 | res.Result = res.Result[:pg.PageSize] 47 | } 48 | return res, nil 49 | } 50 | 51 | // ListMembers ... 52 | func (b *Group) ListMembers(ctx context.Context, kind, uid string, pg tpl.Pagination) (*tpl.GroupMembersRes, error) { 53 | group, err := b.ms.Group.Acquire(context.WithValue(ctx, model.ReadDB, true), kind, uid) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | members, total, err := b.ms.Group.FindMembers(ctx, group.ID, pg) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | res := &tpl.GroupMembersRes{Result: members} 64 | res.TotalSize = total 65 | if len(res.Result) > pg.PageSize { 66 | res.NextPageToken = tpl.IDToPageToken(res.Result[pg.PageSize].ID) 67 | res.Result = res.Result[:pg.PageSize] 68 | } 69 | return res, nil 70 | } 71 | 72 | // ListSettings ... 73 | func (b *Group) ListSettings(ctx context.Context, req tpl.MySettingsQueryURL) (*tpl.MySettingsRes, error) { 74 | group, err := b.ms.Group.Acquire(ctx, req.Kind, req.UID) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | readCtx := context.WithValue(ctx, model.ReadDB, true) 80 | var productID int64 81 | var moduleID int64 82 | var settingID int64 83 | 84 | if req.Product != "" { 85 | productID, err = b.ms.Product.AcquireID(readCtx, req.Product) 86 | if err != nil { 87 | return nil, err 88 | } 89 | } 90 | if productID > 0 && req.Module != "" { 91 | moduleID, err = b.ms.Module.AcquireID(readCtx, productID, req.Module) 92 | if err != nil { 93 | return nil, err 94 | } 95 | } 96 | 97 | if moduleID > 0 && req.Setting != "" { 98 | settingID, err = b.ms.Setting.AcquireID(readCtx, moduleID, req.Setting) 99 | if err != nil { 100 | return nil, err 101 | } 102 | } 103 | 104 | pg := req.Pagination 105 | settings, total, err := b.ms.Group.FindSettings(readCtx, group.ID, productID, moduleID, settingID, pg, req.Channel, req.Client) 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | res := &tpl.MySettingsRes{Result: settings} 111 | res.TotalSize = total 112 | if len(res.Result) > pg.PageSize { 113 | res.NextPageToken = tpl.IDToPageToken(res.Result[pg.PageSize].ID) 114 | res.Result = res.Result[:pg.PageSize] 115 | } 116 | return res, nil 117 | } 118 | 119 | // CheckExists ... 120 | func (b *Group) CheckExists(ctx context.Context, kind, uid string) bool { 121 | group, _ := b.ms.Group.FindByUID(ctx, kind, uid, "id") 122 | return group != nil 123 | } 124 | 125 | // BatchAdd ... 126 | func (b *Group) BatchAdd(ctx context.Context, groups []tpl.GroupBody) error { 127 | return b.ms.Group.BatchAdd(ctx, groups) 128 | } 129 | 130 | // BatchAddMembers 批量给群组添加成员,如果用户未加入系统,则会自动加入 131 | func (b *Group) BatchAddMembers(ctx context.Context, kind, uid string, users []string) error { 132 | group, err := b.ms.Group.Acquire(ctx, kind, uid) 133 | if err != nil { 134 | return err 135 | } 136 | 137 | if err = b.ms.User.BatchAdd(ctx, users); err != nil { 138 | return err 139 | } 140 | 141 | return b.ms.Group.BatchAddMembers(ctx, group, users) 142 | } 143 | 144 | // RemoveMembers ... 145 | func (b *Group) RemoveMembers(ctx context.Context, kind, uid, userUID string, syncLt int64) error { 146 | group, err := b.ms.Group.Acquire(ctx, kind, uid) 147 | if err != nil { 148 | return err 149 | } 150 | 151 | var userID int64 152 | if userUID != "" { 153 | if user, _ := b.ms.User.FindByUID(ctx, userUID, "id"); user != nil { 154 | userID = user.ID 155 | } 156 | } 157 | 158 | return b.ms.Group.RemoveMembers(ctx, group.ID, userID, syncLt) 159 | } 160 | 161 | // Update ... 162 | func (b *Group) Update(ctx context.Context, kind, uid string, body tpl.GroupUpdateBody) (*tpl.GroupRes, error) { 163 | group, err := b.ms.Group.Acquire(ctx, kind, uid) 164 | if err != nil { 165 | return nil, err 166 | } 167 | group, err = b.ms.Group.Update(ctx, group.ID, body.ToMap()) 168 | if err != nil { 169 | return nil, err 170 | } 171 | return &tpl.GroupRes{Result: *group}, nil 172 | } 173 | 174 | // Delete ... 175 | func (b *Group) Delete(ctx context.Context, kind, uid string) error { 176 | group, _ := b.ms.Group.FindByUID(ctx, kind, uid, "id") 177 | if group == nil { 178 | return nil 179 | } 180 | return b.ms.Group.Delete(ctx, group.ID) 181 | } 182 | -------------------------------------------------------------------------------- /doc/paths_group.yaml: -------------------------------------------------------------------------------- 1 | # Group API 2 | /v1/groups/{uid}/labels: 3 | get: 4 | tags: 5 | - Group 6 | summary: 获取指定 uid 群组环境标签列表,支持分页,按照标签指派时间倒序。 7 | security: 8 | - HeaderAuthorizationJWT: {} 9 | parameters: 10 | - $ref: '#/components/parameters/HeaderAuthorization' 11 | - $ref: "#/components/parameters/PathUID" 12 | - $ref: "#/components/parameters/QueryPageSize" 13 | - $ref: "#/components/parameters/QueryPageToken" 14 | - $ref: "#/components/parameters/QueryQ" 15 | responses: 16 | '200': 17 | $ref: '#/components/responses/MyLabelsRes' 18 | 19 | /v1/groups/{uid}/settings: 20 | get: 21 | tags: 22 | - Group 23 | summary: 获取指定 uid 群组的功能模块配置项列表,支持分页,按照配置项指派时间倒序 24 | security: 25 | - HeaderAuthorizationJWT: {} 26 | parameters: 27 | - $ref: '#/components/parameters/HeaderAuthorization' 28 | - $ref: "#/components/parameters/PathUID" 29 | - $ref: "#/components/parameters/QueryProduct" 30 | - $ref: "#/components/parameters/QueryModule" 31 | - $ref: "#/components/parameters/QuerySetting" 32 | - $ref: "#/components/parameters/QueryChannel" 33 | - $ref: "#/components/parameters/QueryClient" 34 | - $ref: "#/components/parameters/QueryPageSize" 35 | - $ref: "#/components/parameters/QueryPageToken" 36 | - $ref: "#/components/parameters/QueryQ" 37 | responses: 38 | '200': 39 | $ref: '#/components/responses/MySettingsRes' 40 | 41 | /v1/groups: 42 | get: 43 | tags: 44 | - Group 45 | summary: 读取群组列表,支持分页,按照创建时间倒序 46 | security: 47 | - HeaderAuthorizationJWT: {} 48 | parameters: 49 | - $ref: '#/components/parameters/HeaderAuthorization' 50 | - in: query 51 | name: kind 52 | description: 查询指定 kind 类型的群组,未提供则查询所有类型 53 | required: false 54 | schema: 55 | type: string 56 | - $ref: "#/components/parameters/QueryPageSize" 57 | - $ref: "#/components/parameters/QueryPageToken" 58 | - $ref: "#/components/parameters/QueryQ" 59 | responses: 60 | '200': 61 | $ref: '#/components/responses/GroupsRes' 62 | 63 | /v1/groups/{uid}/exists: 64 | get: 65 | tags: 66 | - Group 67 | summary: 判断指定 uid 群组是否存在 68 | security: 69 | - HeaderAuthorizationJWT: {} 70 | parameters: 71 | - $ref: '#/components/parameters/HeaderAuthorization' 72 | - $ref: "#/components/parameters/PathUID" 73 | responses: 74 | '200': 75 | $ref: '#/components/responses/BoolRes' 76 | 77 | /v1/groups:batch: 78 | post: 79 | tags: 80 | - Group 81 | summary: 批量添加群组,忽略已存在的群组,群组 uid 必须唯一 82 | security: 83 | - HeaderAuthorizationJWT: {} 84 | parameters: 85 | - $ref: '#/components/parameters/HeaderAuthorization' 86 | requestBody: 87 | $ref: '#/components/requestBodies/GroupsBody' 88 | responses: 89 | '200': 90 | $ref: '#/components/responses/BoolRes' 91 | 92 | /v1/groups/{uid}: 93 | put: 94 | tags: 95 | - Group 96 | summary: 更新指定 uid 群组 97 | security: 98 | - HeaderAuthorizationJWT: {} 99 | parameters: 100 | - $ref: '#/components/parameters/HeaderAuthorization' 101 | - $ref: "#/components/parameters/PathUID" 102 | requestBody: 103 | $ref: '#/components/requestBodies/GroupUpdateBody' 104 | responses: 105 | '200': 106 | $ref: '#/components/responses/GroupRes' 107 | delete: 108 | tags: 109 | - Group 110 | summary: 删除指定 uid 群组 111 | security: 112 | - HeaderAuthorizationJWT: {} 113 | parameters: 114 | - $ref: '#/components/parameters/HeaderAuthorization' 115 | - $ref: "#/components/parameters/PathUID" 116 | responses: 117 | '200': 118 | $ref: '#/components/responses/BoolRes' 119 | 120 | /v1/groups/{uid}/members:batch: 121 | post: 122 | tags: 123 | - Group 124 | summary: 批量添加群组成员,如果群组成员已存在,则会更新成员的 syncAt 值为 group 的 syncAt 值 125 | security: 126 | - HeaderAuthorizationJWT: {} 127 | parameters: 128 | - $ref: '#/components/parameters/HeaderAuthorization' 129 | requestBody: 130 | $ref: '#/components/requestBodies/UsersBody' 131 | responses: 132 | '200': 133 | $ref: '#/components/responses/BoolRes' 134 | 135 | /v1/groups/{uid}/members: 136 | get: 137 | tags: 138 | - Group 139 | summary: 获取指定 uid 群组的成员列表,支持分页,按照成员添加时间倒序 140 | security: 141 | - HeaderAuthorizationJWT: {} 142 | parameters: 143 | - $ref: '#/components/parameters/HeaderAuthorization' 144 | - $ref: "#/components/parameters/PathUID" 145 | - $ref: "#/components/parameters/QueryPageSize" 146 | - $ref: "#/components/parameters/QueryPageToken" 147 | - $ref: "#/components/parameters/QueryQ" 148 | responses: 149 | '200': 150 | $ref: '#/components/responses/GroupMembersRes' 151 | delete: 152 | tags: 153 | - Group 154 | summary: 移除群组指定 user 的成员或批量移除同步时间点小于 syncLt 的成员 155 | security: 156 | - HeaderAuthorizationJWT: {} 157 | parameters: 158 | - $ref: '#/components/parameters/HeaderAuthorization' 159 | - $ref: "#/components/parameters/PathUID" 160 | - in: query 161 | name: user 162 | description: 移除群组指定 user 的成员 163 | required: false 164 | schema: 165 | type: string 166 | - in: query 167 | name: syncLt 168 | description: 批量移除同步时间点小于 syncLt 的成员 169 | required: false 170 | schema: 171 | type: string 172 | format: date-time 173 | responses: 174 | '200': 175 | $ref: '#/components/responses/BoolRes' -------------------------------------------------------------------------------- /src/tpl/group.go: -------------------------------------------------------------------------------- 1 | package tpl 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/teambition/gear" 7 | "github.com/teambition/urbs-setting/src/schema" 8 | ) 9 | 10 | // GroupsBody ... 11 | type GroupsBody struct { 12 | Groups []GroupBody `json:"groups"` 13 | } 14 | 15 | // GroupBody ... 16 | type GroupBody struct { 17 | UID string `json:"uid"` 18 | Kind string `json:"kind"` 19 | Desc string `json:"desc"` 20 | } 21 | 22 | // Validate 实现 gear.BodyTemplate。 23 | func (t *GroupsBody) Validate() error { 24 | if len(t.Groups) == 0 { 25 | return gear.ErrBadRequest.WithMsg("groups emtpy") 26 | } 27 | for _, g := range t.Groups { 28 | if !validIDReg.MatchString(g.UID) { 29 | return gear.ErrBadRequest.WithMsgf("invalid group uid: %s", g.UID) 30 | } 31 | if !validLabelReg.MatchString(g.Kind) { 32 | return gear.ErrBadRequest.WithMsgf("invalid group kind: %s", g.Kind) 33 | } 34 | if len(g.Desc) > 1022 { 35 | return gear.ErrBadRequest.WithMsgf("desc too long: %d", len(g.Desc)) 36 | } 37 | } 38 | return nil 39 | } 40 | 41 | // GroupUpdateBody ... 42 | type GroupUpdateBody struct { 43 | Desc *string `json:"desc"` 44 | SyncAt *int64 `json:"syncAt"` 45 | } 46 | 47 | // Validate 实现 gear.BodyTemplate。 48 | func (t *GroupUpdateBody) Validate() error { 49 | if t.Desc == nil && t.SyncAt == nil { 50 | return gear.ErrBadRequest.WithMsgf("desc or kind or sync_at required") 51 | } 52 | if t.Desc != nil && len(*t.Desc) > 1022 { 53 | return gear.ErrBadRequest.WithMsgf("desc too long: %d", len(*t.Desc)) 54 | } 55 | if t.SyncAt != nil { 56 | now := time.Now().Unix() 57 | if *t.SyncAt < (now-3600) || *t.SyncAt > (now+3600) { 58 | // SyncAt 应该在当前时刻前后范围内 59 | return gear.ErrBadRequest.WithMsgf("invalid sync_at: %d", *t.SyncAt) 60 | } 61 | } 62 | return nil 63 | } 64 | 65 | // ToMap ... 66 | func (t *GroupUpdateBody) ToMap() map[string]interface{} { 67 | changed := make(map[string]interface{}) 68 | if t.Desc != nil { 69 | changed["description"] = *t.Desc 70 | } 71 | if t.SyncAt != nil { 72 | changed["sync_at"] = *t.SyncAt 73 | } 74 | return changed 75 | } 76 | 77 | // GroupsURL ... 78 | type GroupsURL struct { 79 | Pagination 80 | Kind string `json:"kind" query:"kind"` 81 | } 82 | 83 | // GroupMembersURL ... 84 | type GroupMembersURL struct { 85 | Kind string `json:"kind" query:"kind"` 86 | UID string `json:"uid" param:"uid"` 87 | User string `json:"user" query:"user"` // 根据用户 uid 删除一个成员 88 | SyncLt int64 `json:"syncLt" query:"syncLt"` // 或根据 sync_lt 删除同步时间小于指定值的所有成员 89 | } 90 | 91 | // Validate 实现 gear.BodyTemplate。 92 | func (t *GroupMembersURL) Validate() error { 93 | if !validIDReg.MatchString(t.UID) { 94 | return gear.ErrBadRequest.WithMsgf("invalid group uid: %s", t.UID) 95 | } 96 | 97 | if t.User != "" { 98 | if !validIDReg.MatchString(t.User) { 99 | return gear.ErrBadRequest.WithMsgf("invalid user uid: %s", t.User) 100 | } 101 | } else if t.SyncLt != 0 { 102 | if t.SyncLt < 0 || t.SyncLt > (time.Now().UTC().Unix()+3600) { 103 | // 较大的 SyncLt 可以删除整个群组成员!+3600 是防止把毫秒当秒用 104 | return gear.ErrBadRequest.WithMsgf("invalid syncLt: %d", t.SyncLt) 105 | } 106 | } else { 107 | return gear.ErrBadRequest.WithMsg("user or syncLt required") 108 | } 109 | return nil 110 | } 111 | 112 | // GroupsRes ... 113 | type GroupsRes struct { 114 | SuccessResponseType 115 | Result []schema.Group `json:"result"` 116 | } 117 | 118 | // GroupMember ... 119 | type GroupMember struct { 120 | ID int64 `json:"-" db:"id"` 121 | User string `json:"user" db:"uid"` 122 | CreatedAt time.Time `json:"createdAt" db:"created_at"` 123 | SyncAt int64 `json:"syncAt" db:"sync_at"` // 归属关系同步时间戳,1970 以来的秒数,应该与 group.sync_at 相等 124 | } 125 | 126 | // GroupMembersRes ... 127 | type GroupMembersRes struct { 128 | SuccessResponseType 129 | Result []GroupMember `json:"result"` 130 | } 131 | 132 | // GroupRes ... 133 | type GroupRes struct { 134 | SuccessResponseType 135 | Result schema.Group `json:"result"` 136 | } 137 | 138 | // ProductLabelGroupURL ... 139 | type ProductLabelGroupURL struct { 140 | ProductLabelURL 141 | Kind string `json:"kind" query:"kind"` 142 | UID string `json:"uid" param:"uid"` 143 | } 144 | 145 | // Validate 实现 gear.BodyTemplate。 146 | func (t *ProductLabelGroupURL) Validate() error { 147 | if !validIDReg.MatchString(t.UID) { 148 | return gear.ErrBadRequest.WithMsgf("invalid uid: %s", t.UID) 149 | } 150 | if err := t.ProductLabelURL.Validate(); err != nil { 151 | return err 152 | } 153 | return nil 154 | } 155 | 156 | // ProductModuleSettingGroupURL ... 157 | type ProductModuleSettingGroupURL struct { 158 | ProductModuleSettingURL 159 | Kind string `json:"kind" query:"kind"` 160 | UID string `json:"uid" param:"uid"` 161 | } 162 | 163 | // Validate 实现 gear.BodyTemplate。 164 | func (t *ProductModuleSettingGroupURL) Validate() error { 165 | if !validIDReg.MatchString(t.UID) { 166 | return gear.ErrBadRequest.WithMsgf("invalid uid: %s", t.UID) 167 | } 168 | if err := t.ProductModuleSettingURL.Validate(); err != nil { 169 | return err 170 | } 171 | return nil 172 | } 173 | 174 | // GroupURL ... 175 | type GroupURL struct { 176 | Kind string `json:"kind" query:"kind"` 177 | UID string `json:"uid" param:"uid"` 178 | } 179 | 180 | // Validate 实现 gear.BodyTemplate。 181 | func (t *GroupURL) Validate() error { 182 | if !validIDReg.MatchString(t.UID) { 183 | return gear.ErrBadRequest.WithMsgf("invalid uid: %s", t.UID) 184 | } 185 | return nil 186 | } 187 | 188 | // GroupPaginationURL ... 189 | type GroupPaginationURL struct { 190 | Pagination 191 | UID string `json:"uid" param:"uid"` 192 | Kind string `json:"kind" query:"kind"` 193 | } 194 | 195 | // Validate 实现 gear.BodyTemplate。 196 | func (t *GroupPaginationURL) Validate() error { 197 | if !validIDReg.MatchString(t.UID) { 198 | return gear.ErrBadRequest.WithMsgf("invalid uid: %s", t.UID) 199 | } 200 | if err := t.Pagination.Validate(); err != nil { 201 | return err 202 | } 203 | return nil 204 | } 205 | 206 | // GroupKindUID ... 207 | type GroupKindUID struct { 208 | Kind string `json:"kind"` 209 | UID string `json:"uid"` 210 | } 211 | -------------------------------------------------------------------------------- /src/tpl/product.go: -------------------------------------------------------------------------------- 1 | package tpl 2 | 3 | import ( 4 | "github.com/teambition/gear" 5 | "github.com/teambition/urbs-setting/src/schema" 6 | ) 7 | 8 | // ProductUpdateBody ... 9 | type ProductUpdateBody struct { 10 | Desc *string `json:"desc"` 11 | } 12 | 13 | // Validate 实现 gear.BodyTemplate。 14 | func (t *ProductUpdateBody) Validate() error { 15 | if t.Desc == nil { 16 | return gear.ErrBadRequest.WithMsgf("desc required") 17 | } 18 | 19 | if len(*t.Desc) > 1022 { 20 | return gear.ErrBadRequest.WithMsgf("desc too long: %d", len(*t.Desc)) 21 | } 22 | return nil 23 | } 24 | 25 | // ToMap ... 26 | func (t *ProductUpdateBody) ToMap() map[string]interface{} { 27 | changed := make(map[string]interface{}) 28 | if t.Desc != nil { 29 | changed["description"] = *t.Desc 30 | } 31 | return changed 32 | } 33 | 34 | // ProductURL ... 35 | type ProductURL struct { 36 | Product string `json:"product" param:"product"` 37 | } 38 | 39 | // Validate 实现 gear.BodyTemplate。 40 | func (t *ProductURL) Validate() error { 41 | if !validNameReg.MatchString(t.Product) { 42 | return gear.ErrBadRequest.WithMsgf("invalid product name: %s", t.Product) 43 | } 44 | return nil 45 | } 46 | 47 | // UIDProductURL ... 48 | type UIDProductURL struct { 49 | Pagination 50 | UID string `json:"uid" param:"uid"` 51 | Product string `json:"product" query:"product"` 52 | } 53 | 54 | // Validate 实现 gear.BodyTemplate。 55 | func (t *UIDProductURL) Validate() error { 56 | if !validIDReg.MatchString(t.UID) { 57 | return gear.ErrBadRequest.WithMsgf("invalid uid: %s", t.UID) 58 | } 59 | if !validNameReg.MatchString(t.Product) { 60 | return gear.ErrBadRequest.WithMsgf("invalid product name: %s", t.Product) 61 | } 62 | 63 | if err := t.Pagination.Validate(); err != nil { 64 | return err 65 | } 66 | return nil 67 | } 68 | 69 | // ProductPaginationURL ... 70 | type ProductPaginationURL struct { 71 | Pagination 72 | Product string `json:"product" param:"product"` 73 | } 74 | 75 | // Validate 实现 gear.BodyTemplate。 76 | func (t *ProductPaginationURL) Validate() error { 77 | if !validNameReg.MatchString(t.Product) { 78 | return gear.ErrBadRequest.WithMsgf("invalid product name: %s", t.Product) 79 | } 80 | if err := t.Pagination.Validate(); err != nil { 81 | return err 82 | } 83 | return nil 84 | } 85 | 86 | // ProductLabelURL ... 87 | type ProductLabelURL struct { 88 | ProductPaginationURL 89 | Label string `json:"label" param:"label"` 90 | } 91 | 92 | // Validate 实现 gear.BodyTemplate。 93 | func (t *ProductLabelURL) Validate() error { 94 | if !validLabelReg.MatchString(t.Label) { 95 | return gear.ErrBadRequest.WithMsgf("invalid label: %s", t.Label) 96 | } 97 | if err := t.ProductPaginationURL.Validate(); err != nil { 98 | return err 99 | } 100 | return nil 101 | } 102 | 103 | // ProductLabelHIDURL ... 104 | type ProductLabelHIDURL struct { 105 | ProductLabelURL 106 | HID string `json:"hid" param:"hid"` 107 | } 108 | 109 | // Validate 实现 gear.BodyTemplate。 110 | func (t *ProductLabelHIDURL) Validate() error { 111 | if !validHIDReg.MatchString(t.HID) { 112 | return gear.ErrBadRequest.WithMsgf("invalid hid: %s", t.HID) 113 | } 114 | if err := t.ProductLabelURL.Validate(); err != nil { 115 | return err 116 | } 117 | return nil 118 | } 119 | 120 | // ProductLabelUIDURL ... 121 | type ProductLabelUIDURL struct { 122 | ProductLabelURL 123 | UID string `json:"uid" param:"uid"` 124 | } 125 | 126 | // Validate 实现 gear.BodyTemplate。 127 | func (t *ProductLabelUIDURL) Validate() error { 128 | if !validIDReg.MatchString(t.UID) { 129 | return gear.ErrBadRequest.WithMsgf("invalid uid: %s", t.UID) 130 | } 131 | if err := t.ProductLabelURL.Validate(); err != nil { 132 | return err 133 | } 134 | return nil 135 | } 136 | 137 | // ProductModuleURL ... 138 | type ProductModuleURL struct { 139 | ProductPaginationURL 140 | Module string `json:"module" param:"module"` 141 | } 142 | 143 | // Validate 实现 gear.BodyTemplate。 144 | func (t *ProductModuleURL) Validate() error { 145 | if !validNameReg.MatchString(t.Module) { 146 | return gear.ErrBadRequest.WithMsgf("invalid module name: %s", t.Module) 147 | } 148 | if err := t.ProductPaginationURL.Validate(); err != nil { 149 | return err 150 | } 151 | return nil 152 | } 153 | 154 | // ProductModuleSettingURL ... 155 | type ProductModuleSettingURL struct { 156 | ProductModuleURL 157 | Setting string `json:"setting" param:"setting"` 158 | } 159 | 160 | // Validate 实现 gear.BodyTemplate。 161 | func (t *ProductModuleSettingURL) Validate() error { 162 | if !validNameReg.MatchString(t.Setting) { 163 | return gear.ErrBadRequest.WithMsgf("invalid setting name: %s", t.Setting) 164 | } 165 | if err := t.ProductModuleURL.Validate(); err != nil { 166 | return err 167 | } 168 | return nil 169 | } 170 | 171 | // ProductModuleSettingHIDURL ... 172 | type ProductModuleSettingHIDURL struct { 173 | ProductModuleSettingURL 174 | HID string `json:"hid" param:"hid"` 175 | } 176 | 177 | // Validate 实现 gear.BodyTemplate。 178 | func (t *ProductModuleSettingHIDURL) Validate() error { 179 | if !validHIDReg.MatchString(t.HID) { 180 | return gear.ErrBadRequest.WithMsgf("invalid hid: %s", t.HID) 181 | } 182 | if err := t.ProductModuleSettingURL.Validate(); err != nil { 183 | return err 184 | } 185 | return nil 186 | } 187 | 188 | // ProductModuleSettingUIDURL ... 189 | type ProductModuleSettingUIDURL struct { 190 | ProductModuleSettingURL 191 | UID string `json:"uid" param:"uid"` 192 | } 193 | 194 | // Validate 实现 gear.BodyTemplate。 195 | func (t *ProductModuleSettingUIDURL) Validate() error { 196 | if !validIDReg.MatchString(t.UID) { 197 | return gear.ErrBadRequest.WithMsgf("invalid uid: %s", t.UID) 198 | } 199 | if err := t.ProductModuleSettingURL.Validate(); err != nil { 200 | return err 201 | } 202 | return nil 203 | } 204 | 205 | // ProductsRes ... 206 | type ProductsRes struct { 207 | SuccessResponseType 208 | Result []schema.Product `json:"result"` 209 | } 210 | 211 | // ProductRes ... 212 | type ProductRes struct { 213 | SuccessResponseType 214 | Result schema.Product `json:"result"` 215 | } 216 | 217 | // ProductStatistics ... 218 | type ProductStatistics struct { 219 | Labels int64 `json:"labels" db:"labels"` 220 | Modules int64 `json:"modules" db:"modules"` 221 | Settings int64 `json:"settings" db:"settings"` 222 | Release int64 `json:"release" db:"release"` 223 | Status int64 `json:"status" db:"status"` 224 | } 225 | 226 | // ProductStatisticsRes ... 227 | type ProductStatisticsRes struct { 228 | SuccessResponseType 229 | Result ProductStatistics `json:"result"` 230 | } 231 | -------------------------------------------------------------------------------- /src/model/setting_rule.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "hash/crc32" 6 | 7 | "github.com/doug-martin/goqu/v9" 8 | "github.com/doug-martin/goqu/v9/exp" 9 | "github.com/teambition/urbs-setting/src/schema" 10 | "github.com/teambition/urbs-setting/src/service" 11 | "github.com/teambition/urbs-setting/src/tpl" 12 | ) 13 | 14 | // SettingRule ... 15 | type SettingRule struct { 16 | *Model 17 | } 18 | 19 | // ApplyRules ... 20 | func (m *SettingRule) ApplyRules(ctx context.Context, productID, userID int64, kind string) error { 21 | rules := []schema.SettingRule{} 22 | exps := []exp.Expression{goqu.C("kind").Eq(kind)} 23 | if productID > 0 { 24 | exps = append(exps, goqu.C("product_id").Eq(productID)) 25 | } 26 | sd := m.RdDB.From(schema.TableSettingRule). 27 | Where(exps...). 28 | Order(goqu.C("updated_at").Desc()).Limit(1000) 29 | err := sd.Executor().ScanStructsContext(ctx, &rules) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | ids := make([]interface{}, 0) 35 | for _, rule := range rules { 36 | p := rule.ToPercent() 37 | if p > 0 && (int((userID+rule.CreatedAt.Unix())%100) <= p) { 38 | // 百分比规则无效或者用户不在百分比区间内 39 | ids = append(ids, rule.ID) 40 | } 41 | } 42 | 43 | if len(ids) > 0 { 44 | sd := m.DB.Insert(schema.TableUserSetting).Cols("user_id", "setting_id", "rls", "value"). 45 | FromQuery(goqu.From(goqu.T(schema.TableSettingRule).As("t1")). 46 | Select(goqu.V(userID), goqu.I("t1.setting_id"), goqu.I("t1.rls"), goqu.I("t1.value")). 47 | Where(goqu.I("t1.id").In(ids...))). 48 | OnConflict(goqu.DoNothing()) 49 | rowsAffected, err := service.DeResult(sd.Executor().ExecContext(ctx)) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | if rowsAffected > 0 { 55 | settingIDs := make([]int64, 0) 56 | sd := m.DB.Select(goqu.I("t1.setting_id")). 57 | From( 58 | goqu.T(schema.TableSettingRule).As("t1"), 59 | goqu.T(schema.TableUserSetting).As("t2")). 60 | Where( 61 | goqu.I("t1.id").In(ids...), 62 | goqu.I("t1.setting_id").Eq(goqu.I("t2.setting_id")), 63 | goqu.I("t1.rls").Eq(goqu.I("t2.rls")), 64 | goqu.I("t2.user_id").Eq(userID)). 65 | Limit(1000) 66 | if err := sd.Executor().ScanValsContext(ctx, &settingIDs); err != nil { 67 | return err 68 | } 69 | 70 | if len(settingIDs) > 0 { 71 | m.tryIncreaseSettingsStatus(ctx, settingIDs, 1) 72 | } 73 | } 74 | } 75 | return nil 76 | } 77 | 78 | // ApplyRulesToAnonymous ... 79 | func (m *SettingRule) ApplyRulesToAnonymous(ctx context.Context, anonymousID string, productID int64, channel, client string, kind string) ([]tpl.MySetting, error) { 80 | rules := []schema.SettingRule{} 81 | sd := m.RdDB.From(schema.TableSettingRule). 82 | Where(goqu.C("product_id").Eq(productID), goqu.C("kind").Eq(kind)). 83 | Order(goqu.C("updated_at").Desc()).Limit(1000) 84 | err := sd.Executor().ScanStructsContext(ctx, &rules) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | anonID := int64(crc32.ChecksumIEEE([]byte(anonymousID))) 90 | ids := make([]interface{}, 0) 91 | for _, rule := range rules { 92 | p := rule.ToPercent() 93 | if p > 0 && (int((anonID+rule.CreatedAt.Unix())%100) <= p) { 94 | // 百分比规则无效或者用户不在百分比区间内 95 | ids = append(ids, rule.ID) 96 | } 97 | } 98 | 99 | data := make([]tpl.MySetting, 0) 100 | if len(ids) > 0 { 101 | sd := m.RdDB.Select( 102 | goqu.I("t1.rls"), 103 | goqu.I("t1.updated_at").As("assigned_at"), 104 | goqu.I("t1.value"), 105 | goqu.I("t2.id"), 106 | goqu.I("t2.name"), 107 | goqu.I("t2.description"), 108 | goqu.I("t2.channels"), 109 | goqu.I("t2.clients"), 110 | goqu.I("t3.name").As("module")). 111 | From( 112 | goqu.T(schema.TableSettingRule).As("t1"), 113 | goqu.T(schema.TableSetting).As("t2"), 114 | goqu.T(schema.TableModule).As("t3")). 115 | Where( 116 | goqu.I("t1.id").In(ids), 117 | goqu.I("t1.setting_id").Eq(goqu.I("t2.id")), 118 | goqu.I("t2.module_id").Eq(goqu.I("t3.id"))). 119 | Order(goqu.I("t1.updated_at").Desc()) 120 | 121 | scanner, err := sd.Executor().ScannerContext(ctx) 122 | if err != nil { 123 | return nil, err 124 | } 125 | 126 | for scanner.Next() { 127 | mySetting := tpl.MySetting{} 128 | if err := scanner.ScanStruct(&mySetting); err != nil { 129 | scanner.Close() 130 | return nil, err 131 | } 132 | 133 | if mySetting.Channels != "" { 134 | if !tpl.StringSliceHas(tpl.StringToSlice(mySetting.Channels), channel) { 135 | continue // channel 不匹配 136 | } 137 | } 138 | if mySetting.Clients != "" { 139 | if !tpl.StringSliceHas(tpl.StringToSlice(mySetting.Clients), client) { 140 | continue // client 不匹配 141 | } 142 | } 143 | 144 | mySetting.HID = service.IDToHID(mySetting.ID, "setting") 145 | data = append(data, mySetting) 146 | } 147 | 148 | scanner.Close() 149 | if err := scanner.Err(); err != nil { 150 | return nil, err 151 | } 152 | } 153 | return data, nil 154 | } 155 | 156 | // Acquire ... 157 | func (m *SettingRule) Acquire(ctx context.Context, settingRuleID int64) (*schema.SettingRule, error) { 158 | settingRule := &schema.SettingRule{} 159 | if err := m.findOneByID(ctx, schema.TableSettingRule, settingRuleID, settingRule); err != nil { 160 | return nil, err 161 | } 162 | return settingRule, nil 163 | } 164 | 165 | // Find ... 166 | func (m *SettingRule) Find(ctx context.Context, productID, settingID int64) ([]schema.SettingRule, error) { 167 | settingRules := make([]schema.SettingRule, 0) 168 | sd := m.RdDB.From(schema.TableSettingRule). 169 | Where(goqu.C("product_id").Eq(productID), goqu.C("setting_id").Eq(settingID)). 170 | Order(goqu.C("id").Desc()).Limit(10) 171 | 172 | err := sd.Executor().ScanStructsContext(ctx, &settingRules) 173 | if err != nil { 174 | return nil, err 175 | } 176 | return settingRules, nil 177 | } 178 | 179 | // Create ... 180 | func (m *SettingRule) Create(ctx context.Context, settingRule *schema.SettingRule) error { 181 | _, err := m.createOne(ctx, schema.TableSettingRule, settingRule) 182 | return err 183 | } 184 | 185 | // Update ... 186 | func (m *SettingRule) Update(ctx context.Context, settingRuleID int64, changed map[string]interface{}) (*schema.SettingRule, error) { 187 | settingRule := &schema.SettingRule{} 188 | if _, err := m.updateByID(ctx, schema.TableSettingRule, settingRuleID, goqu.Record(changed)); err != nil { 189 | return nil, err 190 | } 191 | if err := m.findOneByID(ctx, schema.TableSettingRule, settingRuleID, settingRule); err != nil { 192 | return nil, err 193 | } 194 | return settingRule, nil 195 | } 196 | 197 | // Delete ... 198 | func (m *SettingRule) Delete(ctx context.Context, id int64) (int64, error) { 199 | return m.deleteByID(ctx, schema.TableSettingRule, id) 200 | } 201 | -------------------------------------------------------------------------------- /src/tpl/label.go: -------------------------------------------------------------------------------- 1 | package tpl 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/teambition/gear" 8 | "github.com/teambition/urbs-setting/src/conf" 9 | "github.com/teambition/urbs-setting/src/schema" 10 | "github.com/teambition/urbs-setting/src/service" 11 | ) 12 | 13 | // LabelBody ... 14 | type LabelBody struct { 15 | Name string `json:"name"` 16 | Desc string `json:"desc"` 17 | Channels *[]string `json:"channels"` 18 | Clients *[]string `json:"clients"` 19 | } 20 | 21 | // Validate 实现 gear.BodyTemplate。 22 | func (t *LabelBody) Validate() error { 23 | if !validLabelReg.MatchString(t.Name) { 24 | return gear.ErrBadRequest.WithMsgf("invalid label: %s", t.Name) 25 | } 26 | if len(t.Desc) > 1022 { 27 | return gear.ErrBadRequest.WithMsgf("desc too long: %d (<= 1022)", len(t.Desc)) 28 | } 29 | if t.Channels != nil { 30 | if len(*t.Channels) > 5 { 31 | return gear.ErrBadRequest.WithMsgf("too many channels: %d", len(*t.Channels)) 32 | } 33 | if !SortStringsAndCheck(*t.Channels) { 34 | return gear.ErrBadRequest.WithMsgf("invalid channels: %v", *t.Channels) 35 | } 36 | for _, channel := range *t.Channels { 37 | if !StringSliceHas(conf.Config.Channels, channel) { 38 | return gear.ErrBadRequest.WithMsgf("invalid channel: %s", channel) 39 | } 40 | } 41 | } 42 | if t.Clients != nil { 43 | if len(*t.Clients) > 10 { 44 | return gear.ErrBadRequest.WithMsgf("too many clients: %d", len(*t.Clients)) 45 | } 46 | if !SortStringsAndCheck(*t.Clients) { 47 | return gear.ErrBadRequest.WithMsgf("invalid clients: %v", *t.Clients) 48 | } 49 | for _, client := range *t.Clients { 50 | if !StringSliceHas(conf.Config.Clients, client) { 51 | return gear.ErrBadRequest.WithMsgf("invalid client: %s", client) 52 | } 53 | } 54 | } 55 | return nil 56 | } 57 | 58 | // LabelUpdateBody ... 59 | type LabelUpdateBody struct { 60 | Desc *string `json:"desc"` 61 | Channels *[]string `json:"channels"` 62 | Clients *[]string `json:"clients"` 63 | } 64 | 65 | // Validate 实现 gear.BodyTemplate。 66 | func (t *LabelUpdateBody) Validate() error { 67 | if t.Desc == nil && t.Channels == nil && t.Clients == nil { 68 | return gear.ErrBadRequest.WithMsgf("desc or channels or clients required") 69 | } 70 | if t.Desc != nil && len(*t.Desc) > 1022 { 71 | return gear.ErrBadRequest.WithMsgf("desc too long: %d", len(*t.Desc)) 72 | } 73 | if t.Channels != nil { 74 | if len(*t.Channels) > 5 { 75 | return gear.ErrBadRequest.WithMsgf("too many channels: %d", len(*t.Channels)) 76 | } 77 | if !SortStringsAndCheck(*t.Channels) { 78 | return gear.ErrBadRequest.WithMsgf("invalid channels: %v", *t.Channels) 79 | } 80 | for _, channel := range *t.Channels { 81 | if !StringSliceHas(conf.Config.Channels, channel) { 82 | return gear.ErrBadRequest.WithMsgf("invalid channel: %s", channel) 83 | } 84 | } 85 | } 86 | if t.Clients != nil { 87 | if len(*t.Clients) > 10 { 88 | return gear.ErrBadRequest.WithMsgf("too many clients: %d", len(*t.Clients)) 89 | } 90 | if !SortStringsAndCheck(*t.Clients) { 91 | return gear.ErrBadRequest.WithMsgf("invalid clients: %v", *t.Clients) 92 | } 93 | for _, client := range *t.Clients { 94 | if !StringSliceHas(conf.Config.Clients, client) { 95 | return gear.ErrBadRequest.WithMsgf("invalid client: %s", client) 96 | } 97 | } 98 | } 99 | return nil 100 | } 101 | 102 | // ToMap ... 103 | func (t *LabelUpdateBody) ToMap() map[string]interface{} { 104 | changed := make(map[string]interface{}) 105 | if t.Desc != nil { 106 | changed["description"] = *t.Desc 107 | } 108 | if t.Channels != nil { 109 | changed["channels"] = strings.Join(*t.Channels, ",") 110 | } 111 | if t.Clients != nil { 112 | changed["clients"] = strings.Join(*t.Clients, ",") 113 | } 114 | return changed 115 | } 116 | 117 | // LabelInfo ... 118 | type LabelInfo struct { 119 | ID int64 `json:"-"` 120 | HID string `json:"hid"` 121 | Product string `json:"product"` 122 | Name string `json:"name"` 123 | Desc string `json:"desc"` 124 | Channels []string `json:"channels"` 125 | Clients []string `json:"clients"` 126 | Status int64 `json:"status"` 127 | Release int64 `json:"release"` 128 | CreatedAt time.Time `json:"createdAt"` 129 | UpdatedAt time.Time `json:"updatedAt"` 130 | OfflineAt *time.Time `json:"offlineAt"` 131 | } 132 | 133 | // LabelInfoFrom create a LabelInfo from schema.Label 134 | func LabelInfoFrom(label schema.Label, product string) LabelInfo { 135 | return LabelInfo{ 136 | ID: label.ID, 137 | HID: service.IDToHID(label.ID, "label"), 138 | Product: product, 139 | Name: label.Name, 140 | Desc: label.Desc, 141 | Channels: StringToSlice(label.Channels), 142 | Clients: StringToSlice(label.Clients), 143 | Status: label.Status, 144 | Release: label.Release, 145 | CreatedAt: label.CreatedAt, 146 | UpdatedAt: label.UpdatedAt, 147 | OfflineAt: label.OfflineAt, 148 | } 149 | } 150 | 151 | // LabelsInfoFrom create a slice of LabelInfo from a slice of schema.Label 152 | func LabelsInfoFrom(labels []schema.Label, product string) []LabelInfo { 153 | res := make([]LabelInfo, len(labels)) 154 | for i, l := range labels { 155 | res[i] = LabelInfoFrom(l, product) 156 | } 157 | return res 158 | } 159 | 160 | // LabelsInfoRes ... 161 | type LabelsInfoRes struct { 162 | SuccessResponseType 163 | Result []LabelInfo `json:"result"` // 空数组也保留 164 | } 165 | 166 | // LabelInfoRes ... 167 | type LabelInfoRes struct { 168 | SuccessResponseType 169 | Result LabelInfo `json:"result"` // 空数组也保留 170 | } 171 | 172 | // MyLabel ... 173 | type MyLabel struct { 174 | ID int64 `json:"-" db:"id"` 175 | HID string `json:"hid"` 176 | Product string `json:"product" db:"product"` 177 | Name string `json:"name" db:"name"` 178 | Desc string `json:"desc" db:"description"` 179 | Release int64 `json:"release" db:"rls"` 180 | AssignedAt time.Time `json:"assignedAt" db:"assigned_at"` 181 | } 182 | 183 | // MyLabelsRes ... 184 | type MyLabelsRes struct { 185 | SuccessResponseType 186 | Result []MyLabel `json:"result"` // 空数组也保留 187 | } 188 | 189 | // CacheLabelsInfoRes ... 190 | type CacheLabelsInfoRes struct { 191 | SuccessResponseType 192 | Timestamp int64 `json:"timestamp"` // labels 数组生成时间 193 | Result []schema.UserCacheLabel `json:"result"` // 空数组也保留 194 | } 195 | 196 | // LabelReleaseInfo ... 197 | type LabelReleaseInfo struct { 198 | Release int64 `json:"release"` 199 | Users []string `json:"users"` 200 | Groups []string `json:"groups"` 201 | } 202 | 203 | // LabelReleaseInfoRes ... 204 | type LabelReleaseInfoRes struct { 205 | SuccessResponseType 206 | Result LabelReleaseInfo `json:"result"` // 空数组也保留 207 | } 208 | -------------------------------------------------------------------------------- /src/model/product.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/doug-martin/goqu/v9" 8 | "github.com/teambition/gear" 9 | "github.com/teambition/urbs-setting/src/schema" 10 | "github.com/teambition/urbs-setting/src/tpl" 11 | "github.com/teambition/urbs-setting/src/util" 12 | ) 13 | 14 | // Product ... 15 | type Product struct { 16 | *Model 17 | } 18 | 19 | // FindByName 根据 name 返回 product 数据 20 | func (m *Product) FindByName(ctx context.Context, name, selectStr string) (*schema.Product, error) { 21 | var err error 22 | product := &schema.Product{} 23 | ok, err := m.findOneByCols(ctx, schema.TableProduct, goqu.Ex{"name": name}, selectStr, product) 24 | if err != nil { 25 | return nil, err 26 | } 27 | if !ok { 28 | return nil, nil 29 | } 30 | return product, nil 31 | } 32 | 33 | // Acquire ... 34 | func (m *Product) Acquire(ctx context.Context, productName string) (*schema.Product, error) { 35 | product, err := m.FindByName(ctx, productName, "") 36 | if err != nil { 37 | return nil, err 38 | } 39 | if product == nil { 40 | return nil, gear.ErrNotFound.WithMsgf("product %s not found", productName) 41 | } 42 | if product.DeletedAt != nil { 43 | return nil, gear.ErrNotFound.WithMsgf("product %s was deleted", productName) 44 | } 45 | if product.OfflineAt != nil { 46 | return nil, gear.ErrNotFound.WithMsgf("product %s was offline", productName) 47 | } 48 | return product, nil 49 | } 50 | 51 | // AcquireID ... 52 | func (m *Product) AcquireID(ctx context.Context, productName string) (int64, error) { 53 | product, err := m.FindByName(ctx, productName, "id, offline_at, deleted_at") 54 | if err != nil { 55 | return 0, err 56 | } 57 | if product == nil { 58 | return 0, gear.ErrNotFound.WithMsgf("product %s not found", productName) 59 | } 60 | if product.DeletedAt != nil { 61 | return 0, gear.ErrNotFound.WithMsgf("product %s was deleted", productName) 62 | } 63 | if product.OfflineAt != nil { 64 | return 0, gear.ErrNotFound.WithMsgf("product %s was offline", productName) 65 | } 66 | return product.ID, nil 67 | } 68 | 69 | // Find 根据条件查找 products 70 | func (m *Product) Find(ctx context.Context, pg tpl.Pagination) ([]schema.Product, int, error) { 71 | products := make([]schema.Product, 0) 72 | cursor := pg.TokenToID() 73 | sdc := m.RdDB.Select(). 74 | From(goqu.T(schema.TableProduct)). 75 | Where( 76 | goqu.C("deleted_at").IsNull(), 77 | goqu.C("offline_at").IsNull()) 78 | 79 | sd := m.RdDB.Select(). 80 | From(goqu.T(schema.TableProduct)). 81 | Where( 82 | goqu.C("id").Lte(cursor), 83 | goqu.C("deleted_at").IsNull(), 84 | goqu.C("offline_at").IsNull()) 85 | 86 | if pg.Q != "" { 87 | sdc = sdc.Where(goqu.C("name").ILike(pg.Q)) 88 | sd = sd.Where(goqu.C("name").ILike(pg.Q)) 89 | } 90 | 91 | sd = sd.Order(goqu.C("id").Desc()).Limit(uint(pg.PageSize + 1)) 92 | 93 | total, err := sdc.CountContext(ctx) 94 | if err != nil { 95 | return nil, 0, err 96 | } 97 | 98 | if err = sd.Executor().ScanStructsContext(ctx, &products); err != nil { 99 | return nil, 0, err 100 | } 101 | return products, int(total), nil 102 | } 103 | 104 | // Create ... 105 | func (m *Product) Create(ctx context.Context, product *schema.Product) error { 106 | rowsAffected, err := m.createOne(ctx, schema.TableProduct, product) 107 | if rowsAffected > 0 { 108 | util.Go(5*time.Second, func(gctx context.Context) { 109 | m.tryIncreaseStatisticStatus(gctx, schema.ProductsTotalSize, 1) 110 | }) 111 | } 112 | return err 113 | } 114 | 115 | // Update 更新指定功能模块 116 | func (m *Product) Update(ctx context.Context, productID int64, changed map[string]interface{}) (*schema.Product, error) { 117 | product := &schema.Product{} 118 | if _, err := m.updateByID(ctx, schema.TableProduct, productID, goqu.Record(changed)); err != nil { 119 | return nil, err 120 | } 121 | if err := m.findOneByID(ctx, schema.TableProduct, productID, product); err != nil { 122 | return nil, err 123 | } 124 | return product, nil 125 | } 126 | 127 | // Offline 下线产品 128 | func (m *Product) Offline(ctx context.Context, productID int64) error { 129 | now := time.Now().UTC() 130 | rowsAffected, err := m.updateByCols(ctx, schema.TableProduct, 131 | goqu.Ex{"id": productID, "offline_at": nil}, 132 | goqu.Record{"offline_at": &now, "status": -1}, 133 | ) 134 | if rowsAffected > 0 { 135 | util.Go(5*time.Second, func(gctx context.Context) { 136 | m.tryIncreaseStatisticStatus(gctx, schema.ProductsTotalSize, -1) 137 | }) 138 | 139 | err = m.offlineLabels(ctx, goqu.Ex{"product_id": productID, "offline_at": nil}) 140 | if err == nil { 141 | err = m.offlineModules(ctx, goqu.Ex{"product_id": productID, "offline_at": nil}) 142 | } 143 | 144 | if err == nil { 145 | util.Go(20*time.Second, func(gctx context.Context) { 146 | m.tryRefreshModulesTotalSize(gctx) 147 | m.tryRefreshSettingsTotalSize(gctx) 148 | }) 149 | } 150 | } 151 | return err 152 | } 153 | 154 | // Delete 对产品进行逻辑删除 155 | func (m *Product) Delete(ctx context.Context, productID int64) error { 156 | now := time.Now().UTC() 157 | _, err := m.updateByID(ctx, schema.TableProduct, productID, goqu.Record{"deleted_at": &now}) 158 | return err 159 | } 160 | 161 | // Statistics 返回产品的统计数据 162 | func (m *Product) Statistics(ctx context.Context, productID int64) (*tpl.ProductStatistics, error) { 163 | res := &tpl.ProductStatistics{} 164 | sd := m.RdDB.Select( 165 | goqu.COUNT("id").As("labels"), 166 | goqu.L("IFNULL(SUM(`status`), 0)").As("status"), 167 | goqu.L("IFNULL(SUM(`rls`), 0)").As("release")). 168 | From(goqu.T(schema.TableLabel)). 169 | Where( 170 | goqu.C("product_id").Eq(productID), 171 | goqu.C("offline_at").IsNull()) 172 | 173 | if _, err := sd.Executor().ScanStructContext(ctx, res); err != nil { 174 | return nil, err 175 | } 176 | 177 | moduleIDs := make([]int64, 0) 178 | sd = m.RdDB.Select("id"). 179 | From(goqu.T(schema.TableModule)). 180 | Where( 181 | goqu.C("product_id").Eq(productID), 182 | goqu.C("offline_at").IsNull()) 183 | if err := sd.Executor().ScanValsContext(ctx, &moduleIDs); err != nil { 184 | return nil, err 185 | } 186 | 187 | if len(moduleIDs) > 0 { 188 | res.Modules = int64(len(moduleIDs)) 189 | sd = m.RdDB.Select( 190 | goqu.COUNT("id").As("settings"), 191 | goqu.L("IFNULL(SUM(`status`), 0)").As("status"), 192 | goqu.L("IFNULL(SUM(`rls`), 0)").As("release")). 193 | From(goqu.T(schema.TableSetting)). 194 | Where( 195 | goqu.C("module_id").In(tpl.Int64SliceToInterface(moduleIDs)...), 196 | goqu.C("offline_at").IsNull()) 197 | 198 | res2 := &tpl.ProductStatistics{} 199 | if _, err := sd.Executor().ScanStructContext(ctx, res2); err != nil { 200 | return nil, err 201 | } 202 | 203 | res.Settings = res2.Settings 204 | res.Status += res2.Status 205 | res.Release += res2.Release 206 | } 207 | return res, nil 208 | } 209 | -------------------------------------------------------------------------------- /src/model/label_rule.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "hash/crc32" 6 | "math/rand" 7 | "time" 8 | 9 | "github.com/doug-martin/goqu/v9" 10 | "github.com/doug-martin/goqu/v9/exp" 11 | "github.com/teambition/urbs-setting/src/schema" 12 | "github.com/teambition/urbs-setting/src/service" 13 | "github.com/teambition/urbs-setting/src/tpl" 14 | "github.com/teambition/urbs-setting/src/util" 15 | ) 16 | 17 | // LabelRule ... 18 | type LabelRule struct { 19 | *Model 20 | } 21 | 22 | // ApplyRules ... 23 | func (m *LabelRule) ApplyRules(ctx context.Context, productID int64, userID int64, excludeLabels []int64, kind string) (int, error) { 24 | rules := []schema.LabelRule{} 25 | exps := []exp.Expression{goqu.C("kind").Eq(kind)} 26 | if productID > 0 { 27 | exps = append(exps, goqu.C("product_id").Eq(productID)) 28 | } 29 | sd := m.RdDB.From(schema.TableLabelRule).Where(exps...).Order(goqu.C("updated_at").Desc()).Limit(200) 30 | err := sd.Executor().ScanStructsContext(ctx, &rules) 31 | if err != nil { 32 | return 0, err 33 | } 34 | // 不把 excludeLabels 放入查询条件,从而尽量复用查询缓存 35 | res, err := m.ComputeUserRule(ctx, userID, excludeLabels, rules) 36 | if err != nil { 37 | return 0, err 38 | } 39 | return res, nil 40 | } 41 | 42 | // ApplyRule ... 43 | func (m *LabelRule) ApplyRule(ctx context.Context, productID int64, userID int64, labelID int64, kind string) (int, error) { 44 | rules := []schema.LabelRule{} 45 | exps := []exp.Expression{ 46 | goqu.C("kind").Eq(kind), 47 | goqu.C("label_id").Eq(labelID), 48 | goqu.C("product_id").Eq(productID), 49 | } 50 | sd := m.RdDB.From(schema.TableLabelRule).Where(exps...).Order(goqu.C("updated_at").Desc()).Limit(200) 51 | err := sd.Executor().ScanStructsContext(ctx, &rules) 52 | if err != nil { 53 | return 0, err 54 | } 55 | res, err := m.ComputeUserRule(ctx, userID, []int64{}, rules) 56 | if err != nil { 57 | return 0, err 58 | } 59 | return res, nil 60 | } 61 | 62 | // ComputeUserRule ... 63 | func (m *LabelRule) ComputeUserRule(ctx context.Context, userID int64, excludeLabels []int64, rules []schema.LabelRule) (int, error) { 64 | ids := make([]interface{}, 0) 65 | labelIDs := make([]int64, 0) 66 | for _, rule := range rules { 67 | if tpl.Int64SliceHas(excludeLabels, rule.LabelID) { 68 | continue 69 | } 70 | 71 | p := rule.ToPercent() 72 | 73 | if p > 0 && (int((userID+rule.CreatedAt.Unix())%100) <= p) { 74 | // 百分比规则无效或者用户不在百分比区间内 75 | ids = append(ids, rule.ID) 76 | labelIDs = append(labelIDs, rule.LabelID) 77 | } 78 | } 79 | 80 | if len(ids) > 0 { 81 | index := rand.Intn(len(ids)) 82 | ids = []interface{}{ids[index]} 83 | labelIDs = []int64{labelIDs[index]} 84 | 85 | sd := m.DB.Insert(schema.TableUserLabel).Cols("user_id", "label_id", "rls"). 86 | FromQuery(goqu.From(goqu.T(schema.TableLabelRule).As("t1")). 87 | Select(goqu.V(userID), goqu.I("t1.label_id"), goqu.I("t1.id")). 88 | Where(goqu.I("t1.id").In(ids...))). 89 | OnConflict(goqu.DoNothing()) 90 | rowsAffected, err := service.DeResult(sd.Executor().ExecContext(ctx)) 91 | if err != nil { 92 | return 0, err 93 | } 94 | 95 | if rowsAffected > 0 { 96 | util.Go(5*time.Second, func(gctx context.Context) { 97 | m.tryIncreaseLabelsStatus(gctx, labelIDs, 1) 98 | }) 99 | } 100 | } 101 | return len(ids), nil 102 | } 103 | 104 | // ApplyRulesToAnonymous ... 105 | func (m *LabelRule) ApplyRulesToAnonymous(ctx context.Context, anonymousID string, productID int64, kind string) ([]schema.UserCacheLabel, error) { 106 | rules := []schema.LabelRule{} 107 | sd := m.RdDB.From(schema.TableLabelRule). 108 | Where( 109 | goqu.C("kind").Eq(kind), 110 | goqu.C("product_id").Eq(productID)). 111 | Order(goqu.C("updated_at").Desc()).Limit(200) 112 | err := sd.Executor().ScanStructsContext(ctx, &rules) 113 | if err != nil { 114 | return nil, err 115 | } 116 | 117 | anonID := int64(crc32.ChecksumIEEE([]byte(anonymousID))) 118 | labelIDs := make([]int64, 0) 119 | for _, rule := range rules { 120 | p := rule.ToPercent() 121 | if p > 0 && (int((anonID+rule.CreatedAt.Unix())%100) <= p) { 122 | // 百分比规则无效或者用户不在百分比区间内 123 | labelIDs = append(labelIDs, rule.LabelID) 124 | } 125 | } 126 | 127 | data := make([]schema.UserCacheLabel, 0) 128 | if len(labelIDs) > 0 { 129 | sd := m.RdDB.Select( 130 | goqu.I("t1.id"), 131 | goqu.I("t1.name"), 132 | goqu.I("t1.channels"), 133 | goqu.I("t1.clients")). 134 | From(goqu.T(schema.TableLabel).As("t1")). 135 | Where(goqu.I("t1.id").In(labelIDs)) 136 | 137 | scanner, err := sd.Executor().ScannerContext(ctx) 138 | if err != nil { 139 | return nil, err 140 | } 141 | 142 | mp := make(map[int64]schema.UserCacheLabel) 143 | for scanner.Next() { 144 | myLabelInfo := schema.MyLabelInfo{} 145 | if err := scanner.ScanStruct(&myLabelInfo); err != nil { 146 | scanner.Close() 147 | return nil, err 148 | } 149 | 150 | mp[myLabelInfo.ID] = schema.UserCacheLabel{ 151 | Label: myLabelInfo.Name, 152 | Clients: tpl.StringToSlice(myLabelInfo.Clients), 153 | Channels: tpl.StringToSlice(myLabelInfo.Channels), 154 | } 155 | } 156 | 157 | scanner.Close() 158 | if err := scanner.Err(); err != nil { 159 | return nil, err 160 | } 161 | 162 | for _, id := range labelIDs { 163 | if label, ok := mp[id]; ok { 164 | data = append(data, label) 165 | } 166 | } 167 | } 168 | 169 | return data, nil 170 | } 171 | 172 | // Acquire ... 173 | func (m *LabelRule) Acquire(ctx context.Context, labelRuleID int64) (*schema.LabelRule, error) { 174 | labelRule := &schema.LabelRule{} 175 | if err := m.findOneByID(ctx, schema.TableLabelRule, labelRuleID, labelRule); err != nil { 176 | return nil, err 177 | } 178 | return labelRule, nil 179 | } 180 | 181 | // Find ... 182 | func (m *LabelRule) Find(ctx context.Context, productID, labelID int64) ([]schema.LabelRule, error) { 183 | labelRules := make([]schema.LabelRule, 0) 184 | sd := m.RdDB.From(schema.TableLabelRule). 185 | Where(goqu.C("product_id").Eq(productID), goqu.C("label_id").Eq(labelID)). 186 | Order(goqu.C("id").Desc()).Limit(10) 187 | 188 | err := sd.Executor().ScanStructsContext(ctx, &labelRules) 189 | if err != nil { 190 | return nil, err 191 | } 192 | return labelRules, nil 193 | } 194 | 195 | // Create ... 196 | func (m *LabelRule) Create(ctx context.Context, labelRule *schema.LabelRule) error { 197 | _, err := m.createOne(ctx, schema.TableLabelRule, labelRule) 198 | return err 199 | } 200 | 201 | // Update ... 202 | func (m *LabelRule) Update(ctx context.Context, labelRuleID int64, changed map[string]interface{}) (*schema.LabelRule, error) { 203 | labelRule := &schema.LabelRule{} 204 | if _, err := m.updateByID(ctx, schema.TableLabelRule, labelRuleID, goqu.Record(changed)); err != nil { 205 | return nil, err 206 | } 207 | if err := m.findOneByID(ctx, schema.TableLabelRule, labelRuleID, labelRule); err != nil { 208 | return nil, err 209 | } 210 | return labelRule, nil 211 | } 212 | 213 | // Delete ... 214 | func (m *LabelRule) Delete(ctx context.Context, id int64) (int64, error) { 215 | return m.deleteByID(ctx, schema.TableLabelRule, id) 216 | } 217 | -------------------------------------------------------------------------------- /src/api/label.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/teambition/gear" 5 | "github.com/teambition/urbs-setting/src/bll" 6 | "github.com/teambition/urbs-setting/src/dto" 7 | "github.com/teambition/urbs-setting/src/service" 8 | "github.com/teambition/urbs-setting/src/tpl" 9 | ) 10 | 11 | // Label .. 12 | type Label struct { 13 | blls *bll.Blls 14 | } 15 | 16 | // List .. 17 | func (a *Label) List(ctx *gear.Context) error { 18 | req := tpl.ProductPaginationURL{} 19 | if err := ctx.ParseURL(&req); err != nil { 20 | return err 21 | } 22 | res, err := a.blls.Label.List(ctx, req.Product, req.Pagination) 23 | if err != nil { 24 | return err 25 | } 26 | return ctx.OkJSON(res) 27 | } 28 | 29 | // Create .. 30 | func (a *Label) Create(ctx *gear.Context) error { 31 | req := tpl.ProductURL{} 32 | if err := ctx.ParseURL(&req); err != nil { 33 | return err 34 | } 35 | 36 | body := tpl.LabelBody{} 37 | if err := ctx.ParseBody(&body); err != nil { 38 | return err 39 | } 40 | 41 | res, err := a.blls.Label.Create(ctx, req.Product, &body) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | return ctx.OkJSON(res) 47 | } 48 | 49 | // Update .. 50 | func (a *Label) Update(ctx *gear.Context) error { 51 | req := tpl.ProductLabelURL{} 52 | if err := ctx.ParseURL(&req); err != nil { 53 | return err 54 | } 55 | 56 | body := tpl.LabelUpdateBody{} 57 | if err := ctx.ParseBody(&body); err != nil { 58 | return err 59 | } 60 | 61 | res, err := a.blls.Label.Update(ctx, req.Product, req.Label, body) 62 | if err != nil { 63 | return err 64 | } 65 | return ctx.OkJSON(res) 66 | } 67 | 68 | // Offline .. 69 | func (a *Label) Offline(ctx *gear.Context) error { 70 | req := tpl.ProductLabelURL{} 71 | if err := ctx.ParseURL(&req); err != nil { 72 | return err 73 | } 74 | res, err := a.blls.Label.Offline(ctx, req.Product, req.Label) 75 | if err != nil { 76 | return err 77 | } 78 | return ctx.OkJSON(res) 79 | } 80 | 81 | // Assign .. 82 | func (a *Label) Assign(ctx *gear.Context) error { 83 | req := tpl.ProductLabelURL{} 84 | if err := ctx.ParseURL(&req); err != nil { 85 | return err 86 | } 87 | 88 | body := tpl.UsersGroupsBody{} 89 | if err := ctx.ParseBody(&body); err != nil { 90 | return err 91 | } 92 | 93 | groups := []*tpl.GroupKindUID{} 94 | for _, uid := range body.Groups { 95 | groups = append(groups, &tpl.GroupKindUID{ 96 | Kind: dto.GroupOrgKind, 97 | UID: uid, 98 | }) 99 | } 100 | 101 | res, err := a.blls.Label.Assign(ctx, req.Product, req.Label, body.Users, groups) 102 | if err != nil { 103 | return err 104 | } 105 | return ctx.OkJSON(tpl.LabelReleaseInfoRes{Result: *res}) 106 | } 107 | 108 | // Delete .. 109 | func (a *Label) Delete(ctx *gear.Context) error { 110 | req := tpl.ProductLabelURL{} 111 | if err := ctx.ParseURL(&req); err != nil { 112 | return err 113 | } 114 | res, err := a.blls.Label.Delete(ctx, req.Product, req.Label) 115 | if err != nil { 116 | return err 117 | } 118 | return ctx.OkJSON(res) 119 | } 120 | 121 | // Recall .. 122 | func (a *Label) Recall(ctx *gear.Context) error { 123 | req := tpl.ProductLabelURL{} 124 | if err := ctx.ParseURL(&req); err != nil { 125 | return err 126 | } 127 | 128 | body := tpl.RecallBody{} 129 | if err := ctx.ParseBody(&body); err != nil { 130 | return err 131 | } 132 | 133 | res, err := a.blls.Label.Recall(ctx, req.Product, req.Label, body.Release) 134 | if err != nil { 135 | return err 136 | } 137 | return ctx.OkJSON(res) 138 | } 139 | 140 | // Cleanup .. 141 | func (a *Label) Cleanup(ctx *gear.Context) error { 142 | req := tpl.ProductLabelURL{} 143 | if err := ctx.ParseURL(&req); err != nil { 144 | return err 145 | } 146 | 147 | res, err := a.blls.Label.Cleanup(ctx, req.Product, req.Label) 148 | if err != nil { 149 | return err 150 | } 151 | return ctx.OkJSON(res) 152 | } 153 | 154 | // CreateRule .. 155 | func (a *Label) CreateRule(ctx *gear.Context) error { 156 | req := tpl.ProductLabelURL{} 157 | if err := ctx.ParseURL(&req); err != nil { 158 | return err 159 | } 160 | 161 | body := tpl.LabelRuleBody{} 162 | if err := ctx.ParseBody(&body); err != nil { 163 | return err 164 | } 165 | 166 | res, err := a.blls.Label.CreateRule(ctx, req.Product, req.Label, body) 167 | if err != nil { 168 | return err 169 | } 170 | 171 | return ctx.OkJSON(res) 172 | } 173 | 174 | // ListRules .. 175 | func (a *Label) ListRules(ctx *gear.Context) error { 176 | req := tpl.ProductLabelURL{} 177 | if err := ctx.ParseURL(&req); err != nil { 178 | return err 179 | } 180 | res, err := a.blls.Label.ListRules(ctx, req.Product, req.Label) 181 | if err != nil { 182 | return err 183 | } 184 | return ctx.OkJSON(res) 185 | } 186 | 187 | // UpdateRule .. 188 | func (a *Label) UpdateRule(ctx *gear.Context) error { 189 | req := tpl.ProductLabelHIDURL{} 190 | if err := ctx.ParseURL(&req); err != nil { 191 | return err 192 | } 193 | 194 | ruleID := service.HIDToID(req.HID, "label_rule") 195 | if ruleID <= 0 { 196 | return gear.ErrBadRequest.WithMsgf("invalid label_rule hid: %s", req.HID) 197 | } 198 | 199 | body := tpl.LabelRuleBody{} 200 | if err := ctx.ParseBody(&body); err != nil { 201 | return err 202 | } 203 | 204 | res, err := a.blls.Label.UpdateRule(ctx, req.Product, req.Label, ruleID, body) 205 | if err != nil { 206 | return err 207 | } 208 | return ctx.OkJSON(res) 209 | } 210 | 211 | // DeleteRule .. 212 | func (a *Label) DeleteRule(ctx *gear.Context) error { 213 | req := tpl.ProductLabelHIDURL{} 214 | if err := ctx.ParseURL(&req); err != nil { 215 | return err 216 | } 217 | 218 | ruleID := service.HIDToID(req.HID, "label_rule") 219 | if ruleID <= 0 { 220 | return gear.ErrBadRequest.WithMsgf("invalid label_rule hid: %s", req.HID) 221 | } 222 | 223 | res, err := a.blls.Label.DeleteRule(ctx, req.Product, req.Label, ruleID) 224 | if err != nil { 225 | return err 226 | } 227 | return ctx.OkJSON(res) 228 | } 229 | 230 | // ListUsers .. 231 | func (a *Label) ListUsers(ctx *gear.Context) error { 232 | req := tpl.ProductLabelURL{} 233 | if err := ctx.ParseURL(&req); err != nil { 234 | return err 235 | } 236 | res, err := a.blls.Label.ListUsers(ctx, req.Product, req.Label, req.Pagination) 237 | if err != nil { 238 | return err 239 | } 240 | return ctx.OkJSON(res) 241 | } 242 | 243 | // DeleteUser .. 244 | func (a *Label) DeleteUser(ctx *gear.Context) error { 245 | req := tpl.ProductLabelUIDURL{} 246 | if err := ctx.ParseURL(&req); err != nil { 247 | return err 248 | } 249 | 250 | res, err := a.blls.Label.DeleteUser(ctx, req.Product, req.Label, req.UID) 251 | if err != nil { 252 | return err 253 | } 254 | return ctx.OkJSON(res) 255 | } 256 | 257 | // ListGroups .. 258 | func (a *Label) ListGroups(ctx *gear.Context) error { 259 | req := tpl.ProductLabelURL{} 260 | if err := ctx.ParseURL(&req); err != nil { 261 | return err 262 | } 263 | res, err := a.blls.Label.ListGroups(ctx, req.Product, req.Label, req.Pagination) 264 | if err != nil { 265 | return err 266 | } 267 | return ctx.OkJSON(res) 268 | } 269 | 270 | // DeleteGroup .. 271 | func (a *Label) DeleteGroup(ctx *gear.Context) error { 272 | req := tpl.ProductLabelGroupURL{} 273 | if err := ctx.ParseURL(&req); err != nil { 274 | return err 275 | } 276 | 277 | res, err := a.blls.Label.DeleteGroup(ctx, req.Product, req.Label, req.Kind, req.UID) 278 | if err != nil { 279 | return err 280 | } 281 | return ctx.OkJSON(res) 282 | } 283 | -------------------------------------------------------------------------------- /src/tpl/common.go: -------------------------------------------------------------------------------- 1 | package tpl 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "regexp" 7 | "sort" 8 | "strings" 9 | 10 | "github.com/teambition/gear" 11 | ) 12 | 13 | var validIDReg = regexp.MustCompile(`^[0-9A-Za-z._=-]{3,63}$`) 14 | var validHIDReg = regexp.MustCompile(`^[0-9A-Za-z_=-]{24}$`) 15 | 16 | // Should be subset of 17 | // https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-subdomain-names 18 | var validNameReg = regexp.MustCompile(`^[0-9a-z][0-9a-z.-]{0,61}[0-9a-z]$`) 19 | 20 | var validValueReg = regexp.MustCompile(`^\S+$`) 21 | 22 | // Should be subset of DNS-1035 label 23 | // https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names 24 | var validLabelReg = regexp.MustCompile(`^[0-9a-z][0-9a-z-]{0,61}[0-9a-z]$`) 25 | 26 | // RandUID for testing 27 | func RandUID() string { 28 | return fmt.Sprintf("uid-%x", randBytes(8)) 29 | } 30 | 31 | // RandName for testing 32 | func RandName() string { 33 | return fmt.Sprintf("name-%x", randBytes(8)) 34 | } 35 | 36 | // RandLabel for testing 37 | func RandLabel() string { 38 | return fmt.Sprintf("label-%x", randBytes(8)) 39 | } 40 | 41 | func randBytes(size int) []byte { 42 | b := make([]byte, size) 43 | if _, err := rand.Read(b); err != nil { 44 | panic("crypto-go: rand.Read() failed, " + err.Error()) 45 | } 46 | return b 47 | } 48 | 49 | // ErrorResponseType 定义了标准的 API 接口错误时返回数据模型 50 | type ErrorResponseType struct { 51 | Error string `json:"error"` 52 | Message string `json:"message"` 53 | } 54 | 55 | // SuccessResponseType 定义了标准的 API 接口成功时返回数据模型 56 | type SuccessResponseType struct { 57 | TotalSize int `json:"totalSize,omitempty"` 58 | NextPageToken string `json:"nextPageToken"` 59 | Result interface{} `json:"result"` 60 | } 61 | 62 | // ResponseType ... 63 | type ResponseType struct { 64 | ErrorResponseType 65 | SuccessResponseType 66 | } 67 | 68 | // BoolRes ... 69 | type BoolRes struct { 70 | SuccessResponseType 71 | Result bool `json:"result"` 72 | } 73 | 74 | // UIDURL ... 75 | type UIDURL struct { 76 | UID string `json:"uid" param:"uid"` 77 | } 78 | 79 | // Validate 实现 gear.BodyTemplate。 80 | func (t *UIDURL) Validate() error { 81 | if !validIDReg.MatchString(t.UID) { 82 | return gear.ErrBadRequest.WithMsgf("invalid uid: %s", t.UID) 83 | } 84 | return nil 85 | } 86 | 87 | // UIDAndProductURL ... 88 | type UIDAndProductURL struct { 89 | UID string `json:"uid" param:"uid"` 90 | Product string `json:"product" query:"product"` 91 | } 92 | 93 | // Validate 实现 gear.BodyTemplate。 94 | func (t *UIDAndProductURL) Validate() error { 95 | if !validIDReg.MatchString(t.UID) { 96 | return gear.ErrBadRequest.WithMsgf("invalid uid: %s", t.UID) 97 | } 98 | if t.Product != "" && !validNameReg.MatchString(t.Product) { 99 | return gear.ErrBadRequest.WithMsgf("invalid product name: %s", t.Product) 100 | } 101 | return nil 102 | } 103 | 104 | // UIDPaginationURL ... 105 | type UIDPaginationURL struct { 106 | Pagination 107 | UID string `json:"uid" param:"uid"` 108 | } 109 | 110 | // Validate 实现 gear.BodyTemplate。 111 | func (t *UIDPaginationURL) Validate() error { 112 | if !validIDReg.MatchString(t.UID) { 113 | return gear.ErrBadRequest.WithMsgf("invalid uid: %s", t.UID) 114 | } 115 | if err := t.Pagination.Validate(); err != nil { 116 | return err 117 | } 118 | return nil 119 | } 120 | 121 | // NameDescBody ... 122 | type NameDescBody struct { 123 | Name string `json:"name"` 124 | Desc string `json:"desc"` 125 | } 126 | 127 | // Validate 实现 gear.BodyTemplate。 128 | func (t *NameDescBody) Validate() error { 129 | if !validNameReg.MatchString(t.Name) { 130 | return gear.ErrBadRequest.WithMsgf("invalid name: %s", t.Name) 131 | } 132 | if len(t.Desc) > 1022 { 133 | return gear.ErrBadRequest.WithMsgf("desc too long: %d (<= 1022)", len(t.Desc)) 134 | } 135 | return nil 136 | } 137 | 138 | // UsersGroupsBody ... 139 | type UsersGroupsBody struct { 140 | Users []string `json:"users"` 141 | Groups []string `json:"groups"` 142 | Value string `json:"value"` 143 | } 144 | 145 | // Validate 实现 gear.BodyTemplate。 146 | func (t *UsersGroupsBody) Validate() error { 147 | if len(t.Users) == 0 && len(t.Groups) == 0 { 148 | return gear.ErrBadRequest.WithMsg("users and groups are empty") 149 | } 150 | 151 | for _, uid := range t.Users { 152 | if !validIDReg.MatchString(uid) { 153 | return gear.ErrBadRequest.WithMsgf("invalid user: %s", uid) 154 | } 155 | } 156 | for _, uid := range t.Groups { 157 | if !validIDReg.MatchString(uid) { 158 | return gear.ErrBadRequest.WithMsgf("invalid group: %s", uid) 159 | } 160 | } 161 | if t.Value != "" && !validValueReg.MatchString(t.Value) { 162 | return gear.ErrBadRequest.WithMsgf("invalid value: %s", t.Value) 163 | } 164 | return nil 165 | } 166 | 167 | // UsersGroupsBodyV2 ... 168 | type UsersGroupsBodyV2 struct { 169 | Users []string `json:"users"` 170 | Groups []*GroupKindUID `json:"groups"` 171 | Value string `json:"value"` 172 | } 173 | 174 | // Validate 实现 gear.BodyTemplate。 175 | func (t *UsersGroupsBodyV2) Validate() error { 176 | if len(t.Users) == 0 && len(t.Groups) == 0 { 177 | return gear.ErrBadRequest.WithMsg("users and groups are empty") 178 | } 179 | 180 | for _, uid := range t.Users { 181 | if !validIDReg.MatchString(uid) { 182 | return gear.ErrBadRequest.WithMsgf("invalid user: %s", uid) 183 | } 184 | } 185 | for _, group := range t.Groups { 186 | if !validIDReg.MatchString(group.UID) { 187 | return gear.ErrBadRequest.WithMsgf("invalid group: %s", group.UID) 188 | } 189 | if !validLabelReg.MatchString(group.Kind) { 190 | return gear.ErrBadRequest.WithMsgf("invalid kind: %s", group.Kind) 191 | } 192 | } 193 | if t.Value != "" && !validValueReg.MatchString(t.Value) { 194 | return gear.ErrBadRequest.WithMsgf("invalid value: %s", t.Value) 195 | } 196 | return nil 197 | } 198 | 199 | // RecallBody ... 200 | type RecallBody struct { 201 | Release int64 `json:"release"` 202 | } 203 | 204 | // Validate 实现 gear.BodyTemplate。 205 | func (t *RecallBody) Validate() error { 206 | if t.Release <= 0 { 207 | return gear.ErrBadRequest.WithMsg("release required") 208 | } 209 | return nil 210 | } 211 | 212 | // StringToSlice ... 213 | func StringToSlice(s string) []string { 214 | if s == "" { 215 | return make([]string, 0) 216 | } 217 | return strings.Split(s, ",") 218 | } 219 | 220 | // StringSliceHas ... 221 | func StringSliceHas(sl []string, v string) bool { 222 | for _, s := range sl { 223 | if v == s { 224 | return true 225 | } 226 | } 227 | return false 228 | } 229 | 230 | // Int64SliceHas ... 231 | func Int64SliceHas(sl []int64, v int64) bool { 232 | for _, s := range sl { 233 | if v == s { 234 | return true 235 | } 236 | } 237 | return false 238 | } 239 | 240 | // SortStringsAndCheck sort string slice and check empty or duplicate value. 241 | func SortStringsAndCheck(sl []string) (ok bool) { 242 | if len(sl) == 0 { 243 | return true 244 | } 245 | if len(sl) == 1 { 246 | return sl[0] != "" 247 | } 248 | 249 | sort.Strings(sl) 250 | for i := 1; i < len(sl); i++ { 251 | if sl[i-1] == "" || sl[i] == sl[i-1] { 252 | return false 253 | } 254 | } 255 | return true 256 | } 257 | 258 | // Int64SliceToInterface ... 259 | func Int64SliceToInterface(s []int64) []interface{} { 260 | v := make([]interface{}, len(s)) 261 | for i := range s { 262 | v[i] = s[i] 263 | } 264 | return v 265 | } 266 | 267 | // StrSliceToInterface ... 268 | func StrSliceToInterface(s []string) []interface{} { 269 | v := make([]interface{}, len(s)) 270 | for i := range s { 271 | v[i] = s[i] 272 | } 273 | return v 274 | } 275 | -------------------------------------------------------------------------------- /src/api/module_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "github.com/DavidCai1993/request" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/teambition/urbs-setting/src/schema" 12 | "github.com/teambition/urbs-setting/src/tpl" 13 | ) 14 | 15 | func createModule(tt *TestTools, productName string) (module schema.Module, err error) { 16 | name := tpl.RandName() 17 | res, err := request.Post(fmt.Sprintf("%s/v1/products/%s/modules", tt.Host, productName)). 18 | Set("Content-Type", "application/json"). 19 | Send(tpl.NameDescBody{Name: name, Desc: name}). 20 | End() 21 | 22 | var product schema.Product 23 | if err == nil { 24 | res.Content() // close http client 25 | _, err = tt.DB.ScanStruct(&product, "select * from `urbs_product` where `name` = ? limit 1", productName) 26 | } 27 | 28 | if err == nil { 29 | _, err = tt.DB.ScanStruct(&module, "select * from `urbs_module` where `product_id` = ? and `name` = ? limit 1", product.ID, name) 30 | } 31 | return 32 | } 33 | 34 | func TestModuleAPIs(t *testing.T) { 35 | tt, cleanup := SetUpTestTools() 36 | defer cleanup() 37 | 38 | product, err := createProduct(tt) 39 | assert.Nil(t, err) 40 | 41 | n1 := tpl.RandName() 42 | 43 | t.Run(`"POST /v1/products/:product/modules"`, func(t *testing.T) { 44 | t.Run("should work", func(t *testing.T) { 45 | assert := assert.New(t) 46 | 47 | res, err := request.Post(fmt.Sprintf("%s/v1/products/%s/modules", tt.Host, product.Name)). 48 | Set("Content-Type", "application/json"). 49 | Send(tpl.NameDescBody{Name: n1, Desc: "test"}). 50 | End() 51 | assert.Nil(err) 52 | assert.Equal(200, res.StatusCode) 53 | 54 | text, err := res.Text() 55 | assert.Nil(err) 56 | assert.True(strings.Contains(text, `"offlineAt":null`)) 57 | assert.False(strings.Contains(text, `"id"`)) 58 | 59 | json := tpl.ModuleRes{} 60 | res.JSON(&json) 61 | assert.NotNil(json.Result) 62 | assert.Equal(n1, json.Result.Name) 63 | assert.Equal("test", json.Result.Desc) 64 | assert.True(json.Result.CreatedAt.UTC().Unix() > int64(0)) 65 | assert.True(json.Result.UpdatedAt.UTC().Unix() > int64(0)) 66 | assert.Nil(json.Result.OfflineAt) 67 | assert.Equal(int64(0), json.Result.Status) 68 | }) 69 | 70 | t.Run(`should return 409`, func(t *testing.T) { 71 | assert := assert.New(t) 72 | 73 | res, err := request.Post(fmt.Sprintf("%s/v1/products/%s/modules", tt.Host, product.Name)). 74 | Set("Content-Type", "application/json"). 75 | Send(tpl.NameDescBody{Name: n1, Desc: "test"}). 76 | End() 77 | assert.Nil(err) 78 | assert.Equal(409, res.StatusCode) 79 | res.Content() // close http client 80 | }) 81 | 82 | t.Run(`should return 400`, func(t *testing.T) { 83 | assert := assert.New(t) 84 | 85 | res, err := request.Post(fmt.Sprintf("%s/v1/products/%s/modules", tt.Host, product.Name)). 86 | Set("Content-Type", "application/json"). 87 | Send(tpl.NameDescBody{Name: ".ab", Desc: "test"}). 88 | End() 89 | assert.Nil(err) 90 | assert.Equal(400, res.StatusCode) 91 | res.Content() // close http client 92 | }) 93 | }) 94 | 95 | t.Run(`"GET /v1/products/:product/modules"`, func(t *testing.T) { 96 | t.Run("should work", func(t *testing.T) { 97 | assert := assert.New(t) 98 | 99 | res, err := request.Get(fmt.Sprintf("%s/v1/products/%s/modules", tt.Host, product.Name)). 100 | End() 101 | assert.Nil(err) 102 | assert.Equal(200, res.StatusCode) 103 | 104 | text, err := res.Text() 105 | assert.Nil(err) 106 | assert.True(strings.Contains(text, n1)) 107 | assert.False(strings.Contains(text, `"id"`)) 108 | 109 | json := tpl.ModulesRes{} 110 | res.JSON(&json) 111 | assert.NotNil(json.Result) 112 | assert.True(len(json.Result) > 0) 113 | }) 114 | }) 115 | 116 | t.Run(`"PUT /v1/products/:product/modules/:module"`, func(t *testing.T) { 117 | product, err := createProduct(tt) 118 | assert.Nil(t, err) 119 | 120 | module, err := createModule(tt, product.Name) 121 | assert.Nil(t, err) 122 | 123 | t.Run("should work", func(t *testing.T) { 124 | assert := assert.New(t) 125 | 126 | desc := "abc" 127 | res, err := request.Put(fmt.Sprintf("%s/v1/products/%s/modules/%s", tt.Host, product.Name, module.Name)). 128 | Set("Content-Type", "application/json"). 129 | Send(tpl.ModuleUpdateBody{ 130 | Desc: &desc, 131 | }). 132 | End() 133 | assert.Nil(err) 134 | assert.Equal(200, res.StatusCode) 135 | 136 | text, err := res.Text() 137 | assert.Nil(err) 138 | assert.True(strings.Contains(text, `"offlineAt":null`)) 139 | assert.False(strings.Contains(text, `"id"`)) 140 | 141 | json := tpl.ModuleRes{} 142 | res.JSON(&json) 143 | assert.NotNil(json.Result) 144 | assert.Equal(module.Name, json.Result.Name) 145 | assert.Equal(desc, json.Result.Desc) 146 | assert.True(json.Result.UpdatedAt.After(json.Result.CreatedAt)) 147 | assert.Nil(json.Result.OfflineAt) 148 | 149 | // should work idempotent 150 | time.Sleep(time.Millisecond * 100) 151 | res, err = request.Put(fmt.Sprintf("%s/v1/products/%s/modules/%s", tt.Host, product.Name, module.Name)). 152 | Set("Content-Type", "application/json"). 153 | Send(tpl.ModuleUpdateBody{ 154 | Desc: &desc, 155 | }). 156 | End() 157 | assert.Nil(err) 158 | assert.Equal(200, res.StatusCode) 159 | 160 | json2 := tpl.ModuleRes{} 161 | res.JSON(&json2) 162 | assert.NotNil(json.Result) 163 | assert.True(json2.Result.UpdatedAt.Equal(json.Result.UpdatedAt)) 164 | }) 165 | 166 | t.Run("should 400", func(t *testing.T) { 167 | assert := assert.New(t) 168 | 169 | res, _ := request.Put(fmt.Sprintf("%s/v1/products/%s/modules/%s", tt.Host, product.Name, module.Name)). 170 | Set("Content-Type", "application/json"). 171 | Send(tpl.ModuleUpdateBody{ 172 | Desc: nil, 173 | }). 174 | End() 175 | assert.Equal(400, res.StatusCode) 176 | res.Content() // close http client 177 | }) 178 | }) 179 | 180 | t.Run(`"PUT /v1/products/:product/modules/:module+:offline"`, func(t *testing.T) { 181 | product, err := createProduct(tt) 182 | assert.Nil(t, err) 183 | 184 | module, err := createModule(tt, product.Name) 185 | assert.Nil(t, err) 186 | 187 | setting, err := createSetting(tt, product.Name, module.Name) 188 | assert.Nil(t, err) 189 | 190 | t.Run("should work", func(t *testing.T) { 191 | assert := assert.New(t) 192 | 193 | res, err := request.Put(fmt.Sprintf("%s/v1/products/%s/modules/%s:offline", tt.Host, product.Name, module.Name)). 194 | End() 195 | assert.Nil(err) 196 | assert.Equal(200, res.StatusCode) 197 | 198 | json := tpl.BoolRes{} 199 | res.JSON(&json) 200 | assert.True(json.Result) 201 | }) 202 | 203 | t.Run("should work idempotent", func(t *testing.T) { 204 | assert := assert.New(t) 205 | 206 | res, err := request.Put(fmt.Sprintf("%s/v1/products/%s/modules/%s:offline", tt.Host, product.Name, module.Name)). 207 | End() 208 | assert.Nil(err) 209 | assert.Equal(200, res.StatusCode) 210 | 211 | json := tpl.BoolRes{} 212 | res.JSON(&json) 213 | assert.False(json.Result) 214 | }) 215 | 216 | t.Run("module's resource should offline", func(t *testing.T) { 217 | assert := assert.New(t) 218 | 219 | assert.Nil(module.OfflineAt) 220 | m := module 221 | 222 | _, err = tt.DB.ScanStruct(&m, "select * from `urbs_module` where `id` = ? limit 1", module.ID) 223 | assert.Nil(err) 224 | assert.NotNil(m.OfflineAt) 225 | 226 | assert.Nil(setting.OfflineAt) 227 | s := setting 228 | _, err = tt.DB.ScanStruct(&s, "select * from `urbs_setting` where `id` = ? limit 1", setting.ID) 229 | assert.Nil(err) 230 | assert.NotNil(s.OfflineAt) 231 | }) 232 | }) 233 | } 234 | -------------------------------------------------------------------------------- /src/bll/user_test.go: -------------------------------------------------------------------------------- 1 | package bll 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/teambition/urbs-setting/src/model" 11 | "github.com/teambition/urbs-setting/src/schema" 12 | "github.com/teambition/urbs-setting/src/service" 13 | "github.com/teambition/urbs-setting/src/tpl" 14 | ) 15 | 16 | func TestUsers(t *testing.T) { 17 | user := &User{ms: model.NewModels(service.NewDB())} 18 | product := &Product{ms: model.NewModels(service.NewDB())} 19 | 20 | t.Run("newUserPercent should work with apply setting rule", func(t *testing.T) { 21 | assert := assert.New(t) 22 | ctx := context.Background() 23 | uid1 := tpl.RandUID() 24 | 25 | user.BatchAdd(ctx, []string{uid1}) 26 | dbUser, err := user.ms.User.FindByUID(context.WithValue(ctx, model.ReadDB, true), uid1, "id") 27 | assert.Nil(err) 28 | assert.True(dbUser.ID > 0) 29 | 30 | userIntID := dbUser.ID 31 | 32 | productName := tpl.RandName() 33 | productRes, err := product.Create(ctx, productName, productName) 34 | 35 | settingRule := &schema.SettingRule{ 36 | ProductID: productRes.Result.ID, 37 | SettingID: 10000, 38 | Kind: schema.RuleNewUserPercent, 39 | Rule: `{"value": 100 }`, 40 | Value: "a", 41 | } 42 | assert.Equal(100, settingRule.ToPercent()) 43 | err = user.ms.SettingRule.Create(ctx, settingRule) 44 | assert.Nil(err) 45 | 46 | body := &tpl.ApplyRulesBody{ 47 | Kind: schema.RuleNewUserPercent, 48 | } 49 | body.Users = []string{uid1} 50 | 51 | user.ApplyRules(context.Background(), productName, body) 52 | time.Sleep(100 * time.Millisecond) 53 | 54 | us := &schema.UserSetting{} 55 | _, err = user.ms.User.DB.ScanStruct(us, "select * from `user_setting` where `user_id` = ? limit 1", userIntID) 56 | assert.Nil(err, err) 57 | assert.Equal("a", us.Value) 58 | assert.Equal(settingRule.SettingID, us.SettingID) 59 | 60 | _, err = user.ms.User.DB.Exec("delete from `user_setting` where `user_id` = ?", userIntID) 61 | assert.Nil(err) 62 | 63 | _, err = user.ms.User.DB.Exec("delete from `setting_rule` where `setting_id` = ?", settingRule.SettingID) 64 | assert.Nil(err) 65 | }) 66 | 67 | t.Run("newUserPercent should work with apply label rule", func(t *testing.T) { 68 | assert := assert.New(t) 69 | ctx := context.Background() 70 | uid1 := tpl.RandUID() 71 | 72 | user.BatchAdd(ctx, []string{uid1}) 73 | dbUser, _ := user.ms.User.FindByUID(context.WithValue(ctx, model.ReadDB, true), uid1, "id") 74 | userIntID := dbUser.ID 75 | 76 | productName := tpl.RandName() 77 | productRes, err := product.Create(ctx, productName, productName) 78 | 79 | labelRule := &schema.LabelRule{ 80 | ProductID: productRes.Result.ID, 81 | LabelID: 10001, 82 | Kind: schema.RuleNewUserPercent, 83 | Rule: `{"value": 100 }`, 84 | } 85 | assert.Equal(100, labelRule.ToPercent()) 86 | err = user.ms.LabelRule.Create(ctx, labelRule) 87 | assert.Nil(err) 88 | 89 | body := &tpl.ApplyRulesBody{ 90 | Kind: schema.RuleNewUserPercent, 91 | } 92 | body.Users = []string{uid1} 93 | 94 | user.ApplyRules(context.Background(), productName, body) 95 | time.Sleep(100 * time.Millisecond) 96 | 97 | ul := &schema.UserLabel{} 98 | _, err = user.ms.User.DB.ScanStruct(ul, "select * from `user_label` where `user_id` = ? limit 1", userIntID) 99 | assert.Nil(err, err) 100 | assert.Equal(labelRule.LabelID, ul.LabelID) 101 | 102 | _, err = user.ms.User.DB.Exec("delete from `user_label` where `user_id` = ?", userIntID) 103 | assert.Nil(err) 104 | 105 | _, err = user.ms.User.DB.Exec("delete from `label_rule` where `label_id` = ?", labelRule.LabelID) 106 | assert.Nil(err) 107 | }) 108 | } 109 | 110 | func TestChildLabelUserPercent(t *testing.T) { 111 | 112 | user := &User{ms: model.NewModels(service.NewDB())} 113 | product := &Product{ms: model.NewModels(service.NewDB())} 114 | 115 | t.Run("childLabelUserPercen should work with apply rule", func(t *testing.T) { 116 | assert := assert.New(t) 117 | require := require.New(t) 118 | ctx := context.Background() 119 | 120 | uid1 := tpl.RandUID() 121 | user.BatchAdd(ctx, []string{uid1}) 122 | userObj, err := user.ms.User.Acquire(ctx, uid1) 123 | assert.Nil(err) 124 | 125 | productName := tpl.RandName() 126 | productRes, err := product.Create(ctx, productName, productName) 127 | 128 | label := &schema.Label{ 129 | ProductID: productRes.Result.ID, 130 | Name: tpl.RandName(), 131 | } 132 | err = user.ms.Label.Create(ctx, label) 133 | assert.Nil(err) 134 | labelRes, err := user.ms.Label.Acquire(ctx, productRes.Result.ID, label.Name) 135 | assert.Nil(err) 136 | 137 | labelRule := &schema.LabelRule{ 138 | ProductID: productRes.Result.ID, 139 | LabelID: labelRes.ID, 140 | Kind: schema.RuleUserPercent, 141 | Rule: `{"value": 100 }`, 142 | } 143 | assert.Equal(100, labelRule.ToPercent()) 144 | err = user.ms.LabelRule.Create(ctx, labelRule) 145 | assert.Nil(err) 146 | 147 | label2 := &schema.Label{ 148 | ProductID: productRes.Result.ID, 149 | Name: labelRes.Name + "-gray", 150 | } 151 | err = user.ms.Label.Create(ctx, label2) 152 | assert.Nil(err) 153 | labelRes2, err := user.ms.Label.Acquire(ctx, productRes.Result.ID, label2.Name) 154 | assert.Nil(err) 155 | 156 | labelRule2 := &schema.LabelRule{ 157 | ProductID: productRes.Result.ID, 158 | LabelID: labelRes2.ID, 159 | Kind: schema.RuleChildLabelUserPercent, 160 | Rule: `{"value": 100 }`, 161 | } 162 | assert.Equal(100, labelRule.ToPercent()) 163 | err = user.ms.LabelRule.Create(ctx, labelRule2) 164 | assert.Nil(err) 165 | 166 | userRes, err := user.ms.ApplyLabelRulesAndRefreshUserLabels(ctx, productRes.Result.ID, productName, userObj.ID, time.Now().UTC(), true) 167 | require.Nil(err) 168 | userLabels := userRes.GetLabels(productName) 169 | assert.True(len(userLabels) == 1) 170 | assert.Equal(labelRes.Name, userLabels[0].Label) 171 | 172 | userRes, err = user.ms.ApplyLabelRulesAndRefreshUserLabels(ctx, productRes.Result.ID, productName, userObj.ID, time.Now().UTC(), true) 173 | assert.Nil(err) 174 | userLabels = userRes.GetLabels(productName) 175 | assert.True(len(userLabels) == 2) 176 | assert.Equal(labelRes2.Name, userLabels[0].Label) 177 | assert.Equal(labelRes.Name, userLabels[1].Label) 178 | }) 179 | } 180 | 181 | func TestUserListCachedLabels(t *testing.T) { 182 | user := &User{ms: model.NewModels(service.NewDB())} 183 | product := &Product{ms: model.NewModels(service.NewDB())} 184 | 185 | require := require.New(t) 186 | ctx := context.Background() 187 | 188 | uid1 := tpl.RandUID() 189 | user.BatchAdd(ctx, []string{uid1}) 190 | userObj, err := user.ms.User.Acquire(ctx, uid1) 191 | require.Nil(err) 192 | 193 | for i := 0; i < 3; i++ { 194 | productName := tpl.RandName() 195 | productRes, err := product.Create(ctx, productName, productName) 196 | 197 | label := &schema.Label{ 198 | ProductID: productRes.Result.ID, 199 | Name: tpl.RandName(), 200 | } 201 | err = user.ms.Label.Create(ctx, label) 202 | require.Nil(err) 203 | 204 | labelRes, err := user.ms.Label.Acquire(ctx, productRes.Result.ID, label.Name) 205 | require.Nil(err) 206 | labelRule := &schema.LabelRule{ 207 | ProductID: productRes.Result.ID, 208 | LabelID: labelRes.ID, 209 | Kind: schema.RuleUserPercent, 210 | Rule: `{"value": 100 }`, 211 | } 212 | require.Equal(100, labelRule.ToPercent()) 213 | err = user.ms.LabelRule.Create(ctx, labelRule) 214 | require.Nil(err) 215 | 216 | res1 := user.ListCachedLabels(ctx, userObj.UID, productName) 217 | require.Equal(1, len(res1.Result), i) 218 | require.Equal(label.Name, res1.Result[0].Label) 219 | time.Sleep(time.Millisecond * 1100) 220 | // test cache 221 | res2 := user.ListCachedLabels(ctx, userObj.UID, productName) 222 | require.Equal(1, len(res2.Result)) 223 | require.Equal(res1.Timestamp, res2.Timestamp) 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /doc/paths_label.yaml: -------------------------------------------------------------------------------- 1 | # Module API 2 | /v1/products/{product}/labels: 3 | get: 4 | tags: 5 | - Label 6 | summary: 读取产品下环境标签列表,支持分页,按照创建时间倒序 7 | security: 8 | - HeaderAuthorizationJWT: {} 9 | parameters: 10 | - $ref: '#/components/parameters/HeaderAuthorization' 11 | - $ref: "#/components/parameters/PathProduct" 12 | - $ref: "#/components/parameters/QueryPageSize" 13 | - $ref: "#/components/parameters/QueryPageToken" 14 | - $ref: "#/components/parameters/QueryQ" 15 | responses: 16 | '200': 17 | $ref: '#/components/responses/LabelsInfoRes' 18 | post: 19 | tags: 20 | - Label 21 | summary: 添加产品环境标签,环境标签 name 在产品下必须唯一 22 | security: 23 | - HeaderAuthorizationJWT: {} 24 | parameters: 25 | - $ref: '#/components/parameters/HeaderAuthorization' 26 | requestBody: 27 | $ref: '#/components/requestBodies/LabelBody' 28 | responses: 29 | '200': 30 | $ref: '#/components/responses/LabelInfoRes' 31 | 32 | /v1/products/{product}/labels/{label}: 33 | put: 34 | tags: 35 | - Label 36 | summary: 更新指定 product name 的产品 37 | security: 38 | - HeaderAuthorizationJWT: {} 39 | parameters: 40 | - $ref: '#/components/parameters/HeaderAuthorization' 41 | - $ref: "#/components/parameters/PathProduct" 42 | - $ref: "#/components/parameters/PathLabel" 43 | requestBody: 44 | $ref: '#/components/requestBodies/LabelUpdateBody' 45 | responses: 46 | '200': 47 | $ref: '#/components/responses/LabelInfoRes' 48 | 49 | /v1/products/{product}/labels/{label}:offline: 50 | put: 51 | tags: 52 | - Label 53 | summary: 将指定产品环境标签下线,所有设置给用户或群组的对应环境标签也会被移除! 54 | security: 55 | - HeaderAuthorizationJWT: {} 56 | parameters: 57 | - $ref: '#/components/parameters/HeaderAuthorization' 58 | - $ref: "#/components/parameters/PathProduct" 59 | - $ref: "#/components/parameters/PathLabel" 60 | responses: 61 | '200': 62 | $ref: '#/components/responses/BoolRes' 63 | 64 | /v1/products/{product}/labels/{label}:assign: 65 | post: 66 | tags: 67 | - Label 68 | summary: 批量为用户或群组设置环境标签 69 | security: 70 | - HeaderAuthorizationJWT: {} 71 | parameters: 72 | - $ref: '#/components/parameters/HeaderAuthorization' 73 | - $ref: "#/components/parameters/PathProduct" 74 | - $ref: "#/components/parameters/PathLabel" 75 | requestBody: 76 | $ref: '#/components/requestBodies/UsersGroupsBody' 77 | responses: 78 | '200': 79 | $ref: '#/components/responses/LabelReleaseInfoRes' 80 | 81 | /v1/products/{product}/labels/{label}:recall: 82 | post: 83 | tags: 84 | - Label 85 | summary: 批量撤销对用户或群组设置的产品环境标签 86 | security: 87 | - HeaderAuthorizationJWT: {} 88 | parameters: 89 | - $ref: '#/components/parameters/HeaderAuthorization' 90 | - $ref: "#/components/parameters/PathProduct" 91 | - $ref: "#/components/parameters/PathLabel" 92 | requestBody: 93 | $ref: '#/components/requestBodies/RecallBody' 94 | responses: 95 | '200': 96 | $ref: '#/components/responses/BoolRes' 97 | 98 | /v1/products/{product}/labels/{label}/users: 99 | get: 100 | tags: 101 | - Label 102 | summary: 读取指定产品环境标签的用户列表 103 | security: 104 | - HeaderAuthorizationJWT: {} 105 | parameters: 106 | - $ref: '#/components/parameters/HeaderAuthorization' 107 | - $ref: "#/components/parameters/PathProduct" 108 | - $ref: "#/components/parameters/PathLabel" 109 | - $ref: "#/components/parameters/QueryPageSize" 110 | - $ref: "#/components/parameters/QueryPageToken" 111 | - $ref: "#/components/parameters/QueryQ" 112 | responses: 113 | '200': 114 | $ref: '#/components/responses/LabelUsersInfoRes' 115 | 116 | /v1/products/{product}/labels/{label}/users/{uid}: 117 | delete: 118 | tags: 119 | - Label 120 | summary: 删除指定产品环境标签的灰度用户 121 | security: 122 | - HeaderAuthorizationJWT: {} 123 | parameters: 124 | - $ref: '#/components/parameters/HeaderAuthorization' 125 | - $ref: "#/components/parameters/PathProduct" 126 | - $ref: "#/components/parameters/PathLabel" 127 | - $ref: "#/components/parameters/PathUID" 128 | responses: 129 | '200': 130 | $ref: '#/components/responses/BoolRes' 131 | 132 | /v1/products/{product}/labels/{label}/groups: 133 | get: 134 | tags: 135 | - Label 136 | summary: 读取指定产品环境标签的群组列表 137 | security: 138 | - HeaderAuthorizationJWT: {} 139 | parameters: 140 | - $ref: '#/components/parameters/HeaderAuthorization' 141 | - $ref: "#/components/parameters/PathProduct" 142 | - $ref: "#/components/parameters/PathLabel" 143 | - $ref: "#/components/parameters/QueryPageSize" 144 | - $ref: "#/components/parameters/QueryPageToken" 145 | - $ref: "#/components/parameters/QueryQ" 146 | responses: 147 | '200': 148 | $ref: '#/components/responses/LabelGroupsInfoRes' 149 | 150 | /v1/products/{product}/labels/{label}/groups/{uid}: 151 | delete: 152 | tags: 153 | - Label 154 | summary: 删除指定产品环境标签的灰度群组 155 | security: 156 | - HeaderAuthorizationJWT: {} 157 | parameters: 158 | - $ref: '#/components/parameters/HeaderAuthorization' 159 | - $ref: "#/components/parameters/PathProduct" 160 | - $ref: "#/components/parameters/PathLabel" 161 | - $ref: "#/components/parameters/PathUID" 162 | responses: 163 | '200': 164 | $ref: '#/components/responses/BoolRes' 165 | 166 | /v1/products/{product}/labels/{label}/rules: 167 | get: 168 | tags: 169 | - Label 170 | summary: 读取指定产品环境标签的灰度发布规则列表 171 | security: 172 | - HeaderAuthorizationJWT: {} 173 | parameters: 174 | - $ref: '#/components/parameters/HeaderAuthorization' 175 | - $ref: "#/components/parameters/PathProduct" 176 | - $ref: "#/components/parameters/PathLabel" 177 | responses: 178 | '200': 179 | $ref: '#/components/responses/LabelRulesInfoRes' 180 | post: 181 | tags: 182 | - Label 183 | summary: 创建指定产品环境标签的灰度发布规则,同一个环境标签同一种 kind 的发布规则只能创建一个 184 | security: 185 | - HeaderAuthorizationJWT: {} 186 | parameters: 187 | - $ref: '#/components/parameters/HeaderAuthorization' 188 | - $ref: "#/components/parameters/PathProduct" 189 | - $ref: "#/components/parameters/PathLabel" 190 | requestBody: 191 | $ref: '#/components/requestBodies/LabelRuleBody' 192 | responses: 193 | '200': 194 | $ref: '#/components/responses/LabelRuleInfoRes' 195 | 196 | /v1/products/{product}/labels/{label}/rules/{hid}: 197 | put: 198 | tags: 199 | - Label 200 | summary: 更新指定产品环境标签的灰度发布规则 201 | security: 202 | - HeaderAuthorizationJWT: {} 203 | parameters: 204 | - $ref: '#/components/parameters/HeaderAuthorization' 205 | - $ref: "#/components/parameters/PathProduct" 206 | - $ref: "#/components/parameters/PathLabel" 207 | - $ref: "#/components/parameters/PathHID" 208 | requestBody: 209 | $ref: '#/components/requestBodies/LabelRuleBody' 210 | responses: 211 | '200': 212 | $ref: '#/components/responses/LabelRuleInfoRes' 213 | delete: 214 | tags: 215 | - Label 216 | summary: 删除指定产品环境标签的灰度发布规则 217 | security: 218 | - HeaderAuthorizationJWT: {} 219 | parameters: 220 | - $ref: '#/components/parameters/HeaderAuthorization' 221 | - $ref: "#/components/parameters/PathProduct" 222 | - $ref: "#/components/parameters/PathLabel" 223 | - $ref: "#/components/parameters/PathHID" 224 | responses: 225 | '200': 226 | $ref: '#/components/responses/BoolRes' --------------------------------------------------------------------------------