zhx dc8435f869 增加幸运礼物 AB 实验:按用户哈希分流灰度发布配置版本
- lucky_gift_experiments/overrides 表(006 迁移,含分组统计索引与 binary user_key)
- 创建实验=同事务发布 treatment+登记;两组强制同策略;实验期常规发布拒绝
- Check/单抽/批量/外部抽按用户钉住组内版本;结束实验重发布获胜快照全量收敛
- AdminLuckyGiftService 新增 5 个实验 RPC(放量字段 proto3 optional)
- admin 实验管理 HTTP API + lucky-gift:experiment 权限(迁移 103)
- 设计文档 docs/幸运礼物AB实验.md;域/真库/admin 回归测试

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:22:12 +08:00

449 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package repository
import (
"os"
"reflect"
"regexp"
"sort"
"strings"
"testing"
)
func TestDefaultPermissionSeedUsesFinancePermissions(t *testing.T) {
permissions := map[string]struct{}{}
for _, permission := range defaultPermissions {
permissions[permission.Code] = struct{}{}
}
for _, code := range []string{"money:view", "money:payment-link:create"} {
if _, ok := permissions[code]; ok {
t.Fatalf("legacy money permission %s must not be part of default seed", code)
}
}
for _, code := range []string{
"payment-temporary-link:create",
"finance:view",
"finance-withdrawal:view",
"finance-withdrawal:audit",
"operations-withdrawal:view",
"operations-withdrawal:audit",
"host-withdrawal:view",
} {
if _, ok := permissions[code]; !ok {
t.Fatalf("finance permission %s missing from default seed", code)
}
}
for _, code := range []string{
"finance-application:create",
"finance-application:audit",
"finance-operation:user-coin-credit",
"finance-operation:user-coin-debit",
"finance-operation:user-wallet-credit",
"finance-operation:user-wallet-debit",
"finance-operation:coin-seller-coin-credit",
"finance-operation:coin-seller-coin-debit",
} {
if _, ok := permissions[code]; ok {
t.Fatalf("removed finance application permission %s must not be seeded", code)
}
}
}
func TestDefaultRoleFinancePermissionTemplates(t *testing.T) {
opsPermissions := seedTestPermissionSet(defaultRolePermissionCodes(roleCodeOperationsLead))
for _, code := range []string{
"payment-bill:export",
"host-withdrawal:view",
"operations-withdrawal:view",
"operations-withdrawal:audit",
} {
if _, ok := opsPermissions[code]; !ok {
t.Fatalf("ops-admin default permissions missing %s", code)
}
}
for _, code := range []string{
"finance:view",
"finance-withdrawal:view",
"finance-withdrawal:audit",
"payment-temporary-link:create",
"payment-third-party:update-rate",
"payment-third-party:sync-rates",
"finance-application:create",
"finance-application:audit",
"finance-operation:user-coin-credit",
"finance-operation:user-coin-debit",
"finance-operation:user-wallet-credit",
"finance-operation:user-wallet-debit",
"finance-operation:coin-seller-coin-credit",
"finance-operation:coin-seller-coin-debit",
} {
if _, ok := opsPermissions[code]; ok {
t.Fatalf("ops-admin must not receive removed permission %s", code)
}
}
}
func TestManagedDefaultRolePermissionMatrix(t *testing.T) {
if len(managedDefaultRoles) != 7 {
t.Fatalf("managed role count = %d, want 7", len(managedDefaultRoles))
}
roleNames := map[string]string{}
for _, role := range managedDefaultRoles {
if previous, ok := roleNames[role.Code]; ok {
t.Fatalf("duplicate role code %s for %s and %s", role.Code, previous, role.Name)
}
roleNames[role.Code] = role.Name
}
wantNames := map[string]string{
roleCodePlatformAdmin: "超级管理员",
roleCodeOperationsLead: "运营负责人",
roleCodeOperationsSpecialist: "运营专员",
roleCodeProductLead: "产品负责人",
roleCodeProductSpecialist: "产品专员",
roleCodeFinanceLead: "财务负责人",
roleCodeFinanceSpecialist: "财务专员",
}
if !reflect.DeepEqual(roleNames, wantNames) {
t.Fatalf("managed roles = %#v, want %#v", roleNames, wantNames)
}
defined := map[string]struct{}{}
for _, permission := range defaultPermissions {
defined[permission.Code] = struct{}{}
}
for _, role := range managedDefaultRoles {
if role.Code == roleCodePlatformAdmin {
continue
}
seen := map[string]struct{}{}
for _, code := range defaultRolePermissionCodes(role.Code) {
if _, ok := seen[code]; ok {
t.Fatalf("role %s contains duplicate permission %s", role.Code, code)
}
seen[code] = struct{}{}
if _, ok := defined[code]; !ok {
t.Fatalf("role %s references permission %s missing from defaultPermissions", role.Code, code)
}
}
}
}
func TestManagedDefaultRoleModuleBoundaries(t *testing.T) {
assertRolePermissions(t, roleCodeOperationsLead,
[]string{"app-config:update", "payment-third-party:sync-methods", "game-catalog:update", "country:update", "external-admin-user:view", "external-admin-user:status", "operations-withdrawal:view", "operations-withdrawal:audit"},
[]string{"app-version:view", "finance:view", "finance-withdrawal:view", "finance-withdrawal:audit", "payment-third-party:update-rate", "role:view", "external-admin-user:create", "external-admin-user:reset-password", "external-admin-user:permissions"},
)
assertRolePermissions(t, roleCodeOperationsSpecialist,
[]string{"room:update", "game-robot:update", "room-rps-config:update", "payment-bill:export", "external-admin-user:view", "external-admin-user:status", "operations-withdrawal:view", "operations-withdrawal:audit"},
[]string{"activity:update", "daily-task:update", "game-catalog:update", "self-game:update", "coin-adjustment:create", "finance-withdrawal:view", "finance-withdrawal:audit", "external-admin-user:create", "external-admin-user:reset-password", "external-admin-user:permissions"},
)
assertRolePermissions(t, roleCodeProductLead,
[]string{"app-version:update", "daily-task:update", "game-catalog:update", "resource:update"},
[]string{"bd:create", "coin-adjustment:create", "country:update", "finance:view"},
)
assertRolePermissions(t, roleCodeProductSpecialist,
[]string{"resource:update", "room-robot:update", "game-catalog:view", "daily-task:view"},
[]string{"app-config:update", "room:update", "game-catalog:update", "daily-task:update", "bd:view"},
)
financeLead := defaultRolePermissionCodes(roleCodeFinanceLead)
financeSpecialist := defaultRolePermissionCodes(roleCodeFinanceSpecialist)
if !reflect.DeepEqual(financeLead, financeSpecialist) {
t.Fatalf("finance lead and specialist permissions differ: lead=%v specialist=%v", financeLead, financeSpecialist)
}
assertRolePermissions(t, roleCodeFinanceLead,
[]string{"finance:view", "payment-bill:view", "payment-bill:export", "finance-withdrawal:audit", "room-rps-order:view"},
[]string{"operations-withdrawal:view", "operations-withdrawal:audit", "game-catalog:view", "room-rps-config:view", "app-user:view", "daily-task:view"},
)
}
func TestLuckyGiftPermissionsStayInManagedRoleSync(t *testing.T) {
assertRolePermissions(t, roleCodeOperationsLead,
[]string{"lucky-gift:view", "lucky-gift:update", "lucky-gift:experiment", "lucky-gift:pool-credit", "lucky-gift:pool-debit"}, nil,
)
assertRolePermissions(t, roleCodeOperationsSpecialist,
[]string{"lucky-gift:view"}, []string{"lucky-gift:update", "lucky-gift:experiment", "lucky-gift:pool-credit", "lucky-gift:pool-debit"},
)
assertRolePermissions(t, roleCodeProductLead,
[]string{"lucky-gift:view", "lucky-gift:update", "lucky-gift:experiment"}, []string{"lucky-gift:pool-credit", "lucky-gift:pool-debit"},
)
assertRolePermissions(t, roleCodeProductSpecialist,
[]string{"lucky-gift:view"}, []string{"lucky-gift:update", "lucky-gift:experiment", "lucky-gift:pool-credit", "lucky-gift:pool-debit"},
)
}
func TestRolePermissionMigrationMatchesManagedMatrix(t *testing.T) {
content, err := os.ReadFile("../../migrations/093_role_permission_matrix.sql")
if err != nil {
t.Fatalf("read role permission migration: %v", err)
}
sqlText := string(content)
for _, roleCode := range []string{
roleCodeOperationsLead,
roleCodeOperationsSpecialist,
roleCodeProductLead,
roleCodeProductSpecialist,
} {
pattern := regexp.MustCompile(`(?s)WHERE role\.code = '` + regexp.QuoteMeta(roleCode) + `'\s+AND permission\.code IN \((.*?)\);`)
matches := pattern.FindStringSubmatch(sqlText)
if len(matches) != 2 {
t.Fatalf("migration permission block missing for %s", roleCode)
}
assertMigrationPermissionCodes(t, roleCode, matches[1])
}
financePattern := regexp.MustCompile(`(?s)WHERE role\.code IN \('finance-lead', 'finance-specialist'\)\s+AND permission\.code IN \((.*?)\);`)
financeMatches := financePattern.FindStringSubmatch(sqlText)
if len(financeMatches) != 2 {
t.Fatal("migration permission block missing for finance roles")
}
assertMigrationPermissionCodes(t, roleCodeFinanceLead, financeMatches[1])
assertMigrationPermissionCodes(t, roleCodeFinanceSpecialist, financeMatches[1])
correction, err := os.ReadFile("../../migrations/102_user_withdrawal_operations_review.sql")
if err != nil {
t.Fatalf("read withdrawal review correction migration: %v", err)
}
correctionSQL := string(correction)
for _, token := range []string{
"schema_migration_checkpoints",
"COALESCE(MAX(id), 0)",
"DEFAULT ''pending''",
"application.id <= checkpoint.checkpoint_value",
"application.status IN ('approved', 'rejected')",
"application.operations_status = 'pending'",
"ALGORITHM=INPLACE, LOCK=NONE",
"DELETE role_permission",
"'ops-admin', 'operations-specialist'",
"'finance-withdrawal:view', 'finance-withdrawal:audit'",
"'platform-admin', 'ops-admin', 'operations-specialist'",
"'operations-withdrawal:view', 'operations-withdrawal:audit'",
} {
if !strings.Contains(correctionSQL, token) {
t.Fatalf("withdrawal review correction migration missing %s", token)
}
}
if strings.Contains(correctionSQL, "DEFAULT ''skipped''") || strings.Contains(correctionSQL, "DEFAULT 'skipped'") {
t.Fatal("withdrawal operations database default must be pending so old gateway inserts cannot bypass initial review")
}
}
func TestGiftRecordPermissionMigrationExtendsOperationsReadRoles(t *testing.T) {
content, err := os.ReadFile("../../migrations/095_gift_record_navigation.sql")
if err != nil {
t.Fatalf("read gift record permission migration: %v", err)
}
sqlText := string(content)
for _, token := range []string{
"'gift-record:view'",
"'ops-admin'",
"'operations-specialist'",
"'product-lead'",
"'product-specialist'",
} {
if !strings.Contains(sqlText, token) {
t.Fatalf("gift record migration missing %s", token)
}
}
}
func TestActivityTemplatePermissionMigrationExtendsManagedRoles(t *testing.T) {
content, err := os.ReadFile("../../migrations/096_activity_template_navigation.sql")
if err != nil {
t.Fatalf("read activity template permission migration: %v", err)
}
sqlText := string(content)
for _, token := range []string{
"'活动模版'", "'activity-template'", "'/activities/templates'",
"'platform-admin', 'ops-admin', 'product-lead'",
"'operations-specialist', 'product-specialist', 'auditor', 'readonly'",
"'activity-template:view'", "'activity-template:create'", "'activity-template:update'",
"'activity-template:publish'", "'activity-template:delete'", "'activity-template:data'",
"'activity-template:export'", "'activity-template:retry'",
} {
if !strings.Contains(sqlText, token) {
t.Fatalf("activity template migration missing %s", token)
}
}
}
func TestExternalAdminMigrationSeedsIsolatedPortalAndOperationsMenu(t *testing.T) {
content, err := os.ReadFile("../../migrations/098_external_admin_portal.sql")
if err != nil {
t.Fatalf("read external admin migration: %v", err)
}
sqlText := string(content)
for _, token := range []string{
"external_admin_accounts", "external_admin_sessions", "external_admin_login_logs", "external_admin_operation_logs",
"uk_external_admin_accounts_app_username", "uk_external_admin_accounts_app_linked_user",
"'external-admin-user:view'", "'external-admin-user:create'", "'external-admin-user:status'", "'external-admin-user:reset-password'",
"'operation-external-admin-users'", "'/operations/external-admin-users'",
"WHERE role.code = 'platform-admin'", "WHERE role.code IN ('ops-admin', 'operations-specialist')",
} {
if !strings.Contains(sqlText, token) {
t.Fatalf("external admin migration missing %s", token)
}
}
// 创建和重置密码会让操作者控制固定的全能力外管账号;迁移必须把这两项
// 仅授予平台管理员,运营岗位只保留日常查看和启停能力。
platformBlock := regexp.MustCompile(`(?s)WHERE role\.code = 'platform-admin'\s+AND permission\.code IN \((.*?)\);`).FindStringSubmatch(sqlText)
if len(platformBlock) != 2 || !strings.Contains(platformBlock[1], "'external-admin-user:create'") || !strings.Contains(platformBlock[1], "'external-admin-user:reset-password'") {
t.Fatal("external admin credential permissions must be granted to platform-admin")
}
opsBlock := regexp.MustCompile(`(?s)WHERE role\.code IN \('ops-admin', 'operations-specialist'\)\s+AND permission\.code IN \((.*?)\);`).FindStringSubmatch(sqlText)
if len(opsBlock) != 2 || !strings.Contains(opsBlock[1], "'external-admin-user:view'") || !strings.Contains(opsBlock[1], "'external-admin-user:status'") {
t.Fatal("external admin operations permissions missing for operations roles")
}
if strings.Contains(opsBlock[1], "'external-admin-user:create'") || strings.Contains(opsBlock[1], "'external-admin-user:reset-password'") {
t.Fatal("operations roles must not receive external admin credential permissions")
}
}
func TestExternalAdminCredentialDelegationCorrectionMigration(t *testing.T) {
content, err := os.ReadFile("../../migrations/099_external_admin_credential_delegation.sql")
if err != nil {
t.Fatalf("read external admin delegation migration: %v", err)
}
sqlText := string(content)
for _, token := range []string{
"DELETE role_permission", "admin_role_permissions", "'ops-admin'", "'operations-specialist'",
"'external-admin-user:create'", "'external-admin-user:reset-password'",
} {
if !strings.Contains(sqlText, token) {
t.Fatalf("external admin delegation migration missing %s", token)
}
}
}
func TestExternalAdminPermissionAssignmentMigrationIsIncrementalAndPlatformOnly(t *testing.T) {
content, err := os.ReadFile("../../migrations/101_external_admin_permission_assignment.sql")
if err != nil {
t.Fatalf("read external admin permission assignment migration: %v", err)
}
sqlText := string(content)
for _, token := range []string{
"'external-admin-user:permissions'", "WHERE role.code = 'platform-admin'",
"admin_role_permissions", "admin_permissions permission", "ADD COLUMN permission_revision",
"BIGINT UNSIGNED NOT NULL DEFAULT 1", "ALGORITHM=INPLACE", "LOCK=NONE",
} {
if !strings.Contains(sqlText, token) {
t.Fatalf("external admin permission assignment migration missing %s", token)
}
}
if strings.Contains(sqlText, "'ops-admin'") || strings.Contains(sqlText, "'operations-specialist'") {
t.Fatal("external permission delegation must not be seeded to operations roles")
}
upperSQL := strings.ToUpper(sqlText)
if strings.Contains(upperSQL, "UPDATE EXTERNAL_ADMIN_ACCOUNTS") || strings.Contains(upperSQL, "DELETE FROM EXTERNAL_ADMIN_ACCOUNTS") {
t.Fatal("permission revision migration must not rewrite account rows in application SQL")
}
}
func TestLuckyGiftPoolAdjustmentPermissionMigrationRepairsManagedRoles(t *testing.T) {
content, err := os.ReadFile("../../migrations/098_lucky_gift_pool_adjust_permissions.sql")
if err != nil {
t.Fatalf("read lucky gift pool permission migration: %v", err)
}
sqlText := string(content)
for _, token := range []string{
"'lucky-gift:view'", "'lucky-gift:update'", "'lucky-gift:pool-credit'", "'lucky-gift:pool-debit'",
"'platform-admin', 'ops-admin'", "'operations-specialist'", "'product-lead'", "'product-specialist'",
} {
if !strings.Contains(sqlText, token) {
t.Fatalf("lucky gift pool permission migration missing %s", token)
}
}
}
func TestLuckyGiftExperimentPermissionMigrationGrantsUpdateRoleGroup(t *testing.T) {
content, err := os.ReadFile("../../migrations/103_lucky_gift_experiment_permission.sql")
if err != nil {
t.Fatalf("read lucky gift experiment permission migration: %v", err)
}
sqlText := string(content)
for _, token := range []string{
"'lucky-gift:experiment'",
"'platform-admin', 'ops-admin', 'product-lead'",
} {
if !strings.Contains(sqlText, token) {
t.Fatalf("lucky gift experiment permission migration missing %s", token)
}
}
// 实验会发布规则版本,但不是资金动作;不能顺手把奖池调账角色组扩进来。
for _, token := range []string{"'operations-specialist'", "'product-specialist'", "'lucky-gift:pool-credit'", "'lucky-gift:pool-debit'"} {
if strings.Contains(sqlText, token) {
t.Fatalf("lucky gift experiment permission migration must not touch %s", token)
}
}
}
func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList string) {
t.Helper()
quotedCode := regexp.MustCompile(`'([a-z0-9:-]+)'`)
matches := quotedCode.FindAllStringSubmatch(sqlList, -1)
actual := make([]string, 0, len(matches))
for _, match := range matches {
actual = append(actual, match[1])
}
expected := append([]string(nil), defaultRolePermissionCodes(roleCode)...)
// 093 已在生产执行不能为了新增页面回写旧迁移095/096/102 以增量方式补充或收紧页面权限。
// 这里仍校验 093 的原始精确矩阵,新权限由上面的专项迁移测试锁定。
expected = stringSetDifference(expected, []string{
"gift-record:view",
"activity-template:view", "activity-template:create", "activity-template:update", "activity-template:publish",
"activity-template:delete", "activity-template:data", "activity-template:export", "activity-template:retry",
"external-admin-user:view", "external-admin-user:create", "external-admin-user:status", "external-admin-user:reset-password",
"lucky-gift:view", "lucky-gift:update", "lucky-gift:pool-credit", "lucky-gift:pool-debit", "lucky-gift:experiment",
})
if roleCode == roleCodeOperationsLead || roleCode == roleCodeOperationsSpecialist {
// 093 仍记录旧的财务提现权限102 才把固定运营岗位切换到独立运营审核权限。
expected = stringSetDifference(expected, []string{"operations-withdrawal:view", "operations-withdrawal:audit"})
expected = append(expected, "finance-withdrawal:view", "finance-withdrawal:audit")
}
sort.Strings(actual)
sort.Strings(expected)
if !reflect.DeepEqual(actual, expected) {
t.Fatalf("migration permissions for %s differ\nactual-only=%v\nexpected-only=%v", roleCode, stringSetDifference(actual, expected), stringSetDifference(expected, actual))
}
}
func stringSetDifference(left []string, right []string) []string {
rightSet := make(map[string]struct{}, len(right))
for _, item := range right {
rightSet[item] = struct{}{}
}
out := make([]string, 0)
for _, item := range left {
if _, ok := rightSet[item]; !ok {
out = append(out, item)
}
}
return out
}
func assertRolePermissions(t *testing.T, roleCode string, required []string, forbidden []string) {
t.Helper()
permissions := seedTestPermissionSet(defaultRolePermissionCodes(roleCode))
for _, code := range required {
if _, ok := permissions[code]; !ok {
t.Errorf("role %s missing required permission %s", roleCode, code)
}
}
for _, code := range forbidden {
if _, ok := permissions[code]; ok {
t.Errorf("role %s must not have permission %s", roleCode, code)
}
}
}
func seedTestPermissionSet(codes []string) map[string]struct{} {
out := make(map[string]struct{}, len(codes))
for _, code := range codes {
out[code] = struct{}{}
}
return out
}