diff --git a/controllers/account/controllers/account_controller.go b/controllers/account/controllers/account_controller.go index f51c06dc2..62da5ff2c 100644 --- a/controllers/account/controllers/account_controller.go +++ b/controllers/account/controllers/account_controller.go @@ -92,6 +92,7 @@ const ( EnvNonFreeTrialEnabled = "NON_FREE_TRIAL_ENABLED" EnvJwtSecret = "ACCOUNT_API_JWT_SECRET" + EnvAdminJwtSecret = "ACCOUNT_ADMIN_JWT_SECRET" EnvDesktopJwtSecret = "DESKTOP_API_JWT_SECRET" InitAccountTimeAnnotation = "user.sealos.io/init-account-time" @@ -122,6 +123,7 @@ type AccountReconciler struct { localDomain string allRegionDomain []string jwtManager *utils.JWTManager + adminJwtManager *utils.JWTManager desktopJwtManager *utils.JWTManager workspaceSubPlans []pkgtypes.WorkspaceSubscriptionPlan workspaceSubPlansResourceLimit map[string]corev1.ResourceList @@ -424,7 +426,19 @@ func (r *AccountReconciler) SetupWithManager(mgr ctrl.Manager, rateOpts controll r.allRegionDomain[i] = region.Domain } r.localDomain = r.AccountV2.GetLocalRegion().Domain - r.jwtManager = utils.NewJWTManager(os.Getenv(EnvJwtSecret), 10*time.Minute) + jwtSecret := os.Getenv(EnvJwtSecret) + if jwtSecret == "" { + return fmt.Errorf("empty jwt secret env: %s", EnvJwtSecret) + } + adminJwtSecret := os.Getenv(EnvAdminJwtSecret) + if adminJwtSecret == "" { + return fmt.Errorf("empty admin jwt secret env: %s", EnvAdminJwtSecret) + } + if adminJwtSecret == jwtSecret { + return fmt.Errorf("admin jwt secret must differ from %s", EnvJwtSecret) + } + r.jwtManager = utils.NewJWTManager(jwtSecret, 10*time.Minute) + r.adminJwtManager = utils.NewJWTManager(adminJwtSecret, 10*time.Minute) plans, err := r.AccountV2.GetWorkspaceSubscriptionPlanList() if err != nil { return fmt.Errorf("failed to get workspace subscription plans: %w", err) diff --git a/controllers/account/controllers/debt.go b/controllers/account/controllers/debt.go index 1ecb3e362..04d5a8dce 100644 --- a/controllers/account/controllers/debt.go +++ b/controllers/account/controllers/debt.go @@ -1119,7 +1119,7 @@ func (r *DebtReconciler) sendFlushDebtResourceStatusRequestWithContext( } for _, domain := range r.allRegionDomain { - token, err := r.jwtManager.GenerateToken(utils.JwtUser{ + token, err := r.adminJwtManager.GenerateAdminToken(utils.JwtUser{ Requester: AdminUserName, }) if err != nil { diff --git a/controllers/account/controllers/property_reload_handler.go b/controllers/account/controllers/property_reload_handler.go index bdf201e2e..5fb266407 100644 --- a/controllers/account/controllers/property_reload_handler.go +++ b/controllers/account/controllers/property_reload_handler.go @@ -33,6 +33,7 @@ type PropertyReloadHandler struct { DBClient database.Interface AccountReconciler *AccountReconciler JwtSecret string + AdminJwtSecret string } func (h *PropertyReloadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -42,7 +43,7 @@ func (h *PropertyReloadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request } // Authenticate admin request - if err := authenticateAdminRequest(r, h.JwtSecret); err != nil { + if err := authenticateAdminRequest(r, h.AdminJwtSecret, h.JwtSecret); err != nil { reloadLogger.Error(err, "admin authentication failed") http.Error(w, "Unauthorized: "+err.Error(), http.StatusUnauthorized) return @@ -83,7 +84,7 @@ func (h *PropertyReloadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request } // authenticateAdminRequest validates that the request is from an admin user -func authenticateAdminRequest(r *http.Request, jwtSecret string) error { +func authenticateAdminRequest(r *http.Request, adminJwtSecret, legacyJwtSecret string) error { tokenString := r.Header.Get("Authorization") if tokenString == "" { return errors.New("authorization header is required") @@ -95,13 +96,20 @@ func authenticateAdminRequest(r *http.Request, jwtSecret string) error { return errors.New("invalid authorization token format") } - // Create JWT manager and verify token - jwtMgr := utils.NewJWTManager(jwtSecret, 0) + if adminJwtSecret != "" { + jwtMgr := utils.NewJWTManager(adminJwtSecret, 0) + if _, err := jwtMgr.ParseAdminUser(token); err == nil { + return nil + } + } + + // Keep accepting tokens signed with the legacy account secret during the + // admin-secret migration. Remove this fallback after all callers migrate. + jwtMgr := utils.NewJWTManager(legacyJwtSecret, 0) user, err := jwtMgr.ParseUser(token) if err != nil { return err } - if user == nil { return errors.New("user not found in token") } diff --git a/controllers/account/controllers/property_reload_handler_test.go b/controllers/account/controllers/property_reload_handler_test.go new file mode 100644 index 000000000..fcf7112d6 --- /dev/null +++ b/controllers/account/controllers/property_reload_handler_test.go @@ -0,0 +1,72 @@ +package controllers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/labring/sealos/controllers/pkg/utils" +) + +func TestAuthenticateAdminRequestUsesDedicatedSecret(t *testing.T) { + adminManager := utils.NewJWTManager("admin-secret", time.Hour) + legacyManager := utils.NewJWTManager("api-secret", time.Hour) + + tests := []struct { + name string + manager *utils.JWTManager + requester string + adminToken bool + wantErr bool + }{ + { + name: "dedicated admin token", + manager: adminManager, + requester: AdminUserName, + adminToken: true, + }, + { + name: "admin key token without admin claims", + manager: adminManager, + requester: AdminUserName, + wantErr: true, + }, + { + name: "legacy admin token", + manager: legacyManager, + requester: AdminUserName, + }, + { + name: "ordinary api token", + manager: legacyManager, + requester: "user", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var token string + var err error + if tt.adminToken { + token, err = tt.manager.GenerateAdminToken(utils.JwtUser{Requester: tt.requester}) + } else { + token, err = tt.manager.GenerateToken(utils.JwtUser{Requester: tt.requester}) + } + if err != nil { + t.Fatalf("generate token: %v", err) + } + + req := httptest.NewRequestWithContext( + context.Background(), http.MethodPost, "/reload-property-types", nil, + ) + req.Header.Set("Authorization", "Bearer "+token) + err = authenticateAdminRequest(req, "admin-secret", "api-secret") + if (err != nil) != tt.wantErr { + t.Fatalf("authenticateAdminRequest() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/controllers/account/controllers/subscription.go b/controllers/account/controllers/subscription.go index 6e51edf54..67db221ef 100644 --- a/controllers/account/controllers/subscription.go +++ b/controllers/account/controllers/subscription.go @@ -214,7 +214,7 @@ func (sp *SubscriptionProcessor) updateQuota( return nil } -const AdminUserName = "sealos-admin" +const AdminUserName = utils.AdminJWTRequester type AdminFlushSubscriptionQuotaReq struct { UserUID uuid.UUID `json:"userUID" bson:"userUID"` @@ -227,7 +227,7 @@ func (sp *SubscriptionProcessor) sendFlushQuotaRequest( userUID, planID uuid.UUID, planName string, ) error { - return sendFlushQuotaRequest(sp.allRegionDomain, sp.jwtManager, userUID, planID, planName) + return sendFlushQuotaRequest(sp.allRegionDomain, sp.adminJwtManager, userUID, planID, planName) } func sendFlushQuotaRequest( @@ -237,7 +237,7 @@ func sendFlushQuotaRequest( planName string, ) error { for _, domain := range allRegion { - token, err := jwtManager.GenerateToken(utils.JwtUser{ + token, err := jwtManager.GenerateAdminToken(utils.JwtUser{ Requester: AdminUserName, }) if err != nil { diff --git a/controllers/account/controllers/user_traffic_controller.go b/controllers/account/controllers/user_traffic_controller.go index e0de6e24a..af2ca4f5d 100644 --- a/controllers/account/controllers/user_traffic_controller.go +++ b/controllers/account/controllers/user_traffic_controller.go @@ -271,7 +271,7 @@ func (c *UserTrafficController) sendResumeUserTrafficRequest(userUID uuid.UUID) func (c *UserTrafficController) sendUserTrafficRequest(userUID uuid.UUID, operator string) error { for _, domain := range c.allRegionDomain { - token, err := c.jwtManager.GenerateToken(utils.JwtUser{ + token, err := c.adminJwtManager.GenerateAdminToken(utils.JwtUser{ Requester: AdminUserName, }) if err != nil { diff --git a/controllers/account/deploy/README.md b/controllers/account/deploy/README.md index cdbeaf82e..24ffedf69 100644 --- a/controllers/account/deploy/README.md +++ b/controllers/account/deploy/README.md @@ -22,13 +22,14 @@ sealos run account-controller:latest \ ## 配置说明 -### 自动配置(从 ConfigMap 获取) +### 自动配置(从 ConfigMap 和 Secret 获取) 以下配置会自动从 `sealos-system/sealos-config` ConfigMap 中读取(如果存在): - `cloudDomain` - 云平台域名 - `cloudPort` - 云平台端口 - `accountApiJwtSecret` - JWT 密钥 +- `accountAdminJwtSecret` - admin API 专用 JWT 密钥,由脚本从 `account-admin-jwt` Secret 读取,不存在时自动生成 - `localRegion` - 本地区域 UID - `globalCockroachURI` - 全局 CockroachDB URI - `localCockroachURI` - 本地 CockroachDB URI @@ -52,6 +53,7 @@ sealos run account-controller:latest \ | `accountEnv.cloudDomain` | `cloud.sealos.io` | 云平台域名 | | `accountEnv.cloudPort` | - | 云平台端口 | | `accountEnv.accountApiJwtSecret` | `secret` | JWT 密钥 | +| `accountEnv.accountAdminJwtSecret` | - | admin API 专用 JWT 密钥 | | `accountEnv.localRegion` | - | 本地区域 UID | | `accountEnv.globalCockroachURI` | - | 全局 CockroachDB URI | | `accountEnv.localCockroachURI` | - | 本地 CockroachDB URI | @@ -165,7 +167,7 @@ sealos run account-controller:latest \ - CRD 资源(accounts、debts、payments) - RBAC 资源(ClusterRole、ClusterRoleBinding) - Namespace 资源 -- 命名空间内资源(ConfigMap、Service、Deployment、ServiceAccount、Role、RoleBinding、Issuer、Certificate) +- 命名空间内资源(ConfigMap、Secret、Service、Deployment、ServiceAccount、Role、RoleBinding、Issuer、Certificate) 如需禁用备份: @@ -185,5 +187,5 @@ sealos run account-controller:latest --env ACCOUNT_BACKUP_ENABLED=false 1. **自动配置**:脚本会自动从 `sealos-system/sealos-config` ConfigMap 读取基础配置,无需手动传入 2. **配置覆盖**:通过 `HELM_OPTS` 传入的参数会覆盖自动获取的配置和默认值 -3. **Secret 安全**:生产环境请务必修改 `accountEnv.accountApiJwtSecret` 等敏感配置 +3. **Secret 安全**:生产环境请妥善保护 `accountEnv.accountApiJwtSecret` 和 `accountEnv.accountAdminJwtSecret`,两者用途不同 4. **持久化存储**:确保 MongoDB 和 CockroachDB 的连接配置正确 diff --git a/controllers/account/deploy/account-controller-entrypoint.sh b/controllers/account/deploy/account-controller-entrypoint.sh index 852144942..1de80b361 100644 --- a/controllers/account/deploy/account-controller-entrypoint.sh +++ b/controllers/account/deploy/account-controller-entrypoint.sh @@ -34,6 +34,13 @@ get_cm_value() { kubectl get configmap "${name}" -n "${namespace}" -o "jsonpath={.data.${key}}" 2>/dev/null || true } +get_secret_value() { + local namespace="$1" + local name="$2" + local key="$3" + kubectl get secret "${name}" -n "${namespace}" -o "jsonpath={.data.${key}}" 2>/dev/null | base64 --decode 2>/dev/null || true +} + backup_ns_resource() { local kind="$1" local name="$2" @@ -77,6 +84,7 @@ backup_account_resources() { fi backup_ns_resource configmap account-manager-config backup_ns_resource configmap account-manager-env + backup_ns_resource secret account-admin-jwt backup_ns_resource service account-controller-manager-metrics-service backup_ns_resource service account-webhook-service backup_ns_resource deployment account-controller-manager @@ -97,6 +105,10 @@ AUTO_CONFIG_HELM_OPTS="" CLOUD_DOMAIN=$(get_cm_value sealos-system sealos-config cloudDomain) CLOUD_PORT=$(get_cm_value sealos-system sealos-config cloudPort) JWT_INTERNAL=$(get_cm_value sealos-system sealos-config jwtInternal) +ACCOUNT_ADMIN_JWT_SECRET=$(get_secret_value "${RELEASE_NAMESPACE}" account-admin-jwt ACCOUNT_ADMIN_JWT_SECRET) +if [ -z "${ACCOUNT_ADMIN_JWT_SECRET}" ]; then + ACCOUNT_ADMIN_JWT_SECRET=$(openssl rand -hex 32) +fi REGION_UID=$(get_cm_value sealos-system sealos-config regionUID) GLOBAL_COCKROACH_URI=$(get_cm_value sealos-system sealos-config databaseGlobalCockroachdbURI) LOCAL_COCKROACH_URI=$(get_cm_value sealos-system sealos-config databaseLocalCockroachdbURI) @@ -110,6 +122,7 @@ fi [ -n "${CLOUD_DOMAIN}" ] && AUTO_CONFIG_HELM_OPTS="${AUTO_CONFIG_HELM_OPTS} --set-string accountEnv.cloudDomain=${CLOUD_DOMAIN}" [ -n "${CLOUD_PORT}" ] && AUTO_CONFIG_HELM_OPTS="${AUTO_CONFIG_HELM_OPTS} --set-string accountEnv.cloudPort=${CLOUD_PORT}" [ -n "${JWT_INTERNAL}" ] && AUTO_CONFIG_HELM_OPTS="${AUTO_CONFIG_HELM_OPTS} --set-string accountEnv.accountApiJwtSecret=${JWT_INTERNAL}" +[ -n "${ACCOUNT_ADMIN_JWT_SECRET}" ] && AUTO_CONFIG_HELM_OPTS="${AUTO_CONFIG_HELM_OPTS} --set-string accountEnv.accountAdminJwtSecret=${ACCOUNT_ADMIN_JWT_SECRET}" [ -n "${REGION_UID}" ] && AUTO_CONFIG_HELM_OPTS="${AUTO_CONFIG_HELM_OPTS} --set-string accountEnv.localRegion=${REGION_UID}" [ -n "${GLOBAL_COCKROACH_URI}" ] && AUTO_CONFIG_HELM_OPTS="${AUTO_CONFIG_HELM_OPTS} --set-string accountEnv.globalCockroachURI=${GLOBAL_COCKROACH_URI}" [ -n "${LOCAL_COCKROACH_URI}" ] && AUTO_CONFIG_HELM_OPTS="${AUTO_CONFIG_HELM_OPTS} --set-string accountEnv.localCockroachURI=${LOCAL_COCKROACH_URI}" @@ -129,6 +142,7 @@ if ! helm status "${RELEASE_NAME}" -n "${RELEASE_NAMESPACE}" >/dev/null 2>&1; th adopt_namespaced_resource configmap account-manager-config adopt_namespaced_resource configmap account-manager-env + adopt_namespaced_resource secret account-admin-jwt adopt_namespaced_resource service account-controller-manager-metrics-service adopt_namespaced_resource service account-webhook-service adopt_namespaced_resource deployment account-controller-manager diff --git a/controllers/account/deploy/charts/account-controller/templates/deployment.yaml b/controllers/account/deploy/charts/account-controller/templates/deployment.yaml index d3258ed5a..2bc61e4a9 100644 --- a/controllers/account/deploy/charts/account-controller/templates/deployment.yaml +++ b/controllers/account/deploy/charts/account-controller/templates/deployment.yaml @@ -15,6 +15,7 @@ spec: annotations: kubectl.kubernetes.io/default-container: manager checksum/account-manager-env: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/account-admin-secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} {{- with .Values.podAnnotations }} {{- toYaml . | nindent 8 }} {{- end }} @@ -63,6 +64,8 @@ spec: optional: true - configMapRef: name: account-manager-env + - secretRef: + name: {{ .Values.adminJwtSecretName }} ports: - containerPort: 9443 name: webhook-server diff --git a/controllers/account/deploy/charts/account-controller/templates/secret.yaml b/controllers/account/deploy/charts/account-controller/templates/secret.yaml new file mode 100644 index 000000000..1343e8391 --- /dev/null +++ b/controllers/account/deploy/charts/account-controller/templates/secret.yaml @@ -0,0 +1,13 @@ +{{- if .Values.accountEnv.accountAdminJwtSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Values.adminJwtSecretName }} + namespace: {{ .Release.Namespace }} + labels: + control-plane: controller-manager + {{- include "account-controller.labels" . | nindent 4 }} +type: Opaque +stringData: + ACCOUNT_ADMIN_JWT_SECRET: {{ .Values.accountEnv.accountAdminJwtSecret | quote }} +{{- end }} diff --git a/controllers/account/deploy/charts/account-controller/values.yaml b/controllers/account/deploy/charts/account-controller/values.yaml index 8aace57f6..87a99af9c 100644 --- a/controllers/account/deploy/charts/account-controller/values.yaml +++ b/controllers/account/deploy/charts/account-controller/values.yaml @@ -13,6 +13,8 @@ serviceAccount: annotations: {} name: "account-controller-manager" +adminJwtSecretName: "account-admin-jwt" + podAnnotations: {} podLabels: {} @@ -59,6 +61,7 @@ accountEnv: # Authentication secrets (auto-configured from sealos-config) accountApiJwtSecret: "secret" # Auto-fetched from sealos-config.jwtInternal + accountAdminJwtSecret: "" # Generated by account-controller-entrypoint.sh # Maximum historical billing window replayed from an existing checkpoint. billingMaxCatchupDuration: "24h" diff --git a/controllers/account/main.go b/controllers/account/main.go index 839375031..3b1f67e47 100644 --- a/controllers/account/main.go +++ b/controllers/account/main.go @@ -316,11 +316,13 @@ func main() { mgr.GetWebhookServer(). Register("/validate-v1-sealos-cloud", &webhook.Admission{Handler: &accountv1.DebtValidate{Client: mgr.GetClient(), AccountV2: v2Account, TTLUserMap: maps.New[*types.UsableBalanceWithCredits](env.GetIntEnvWithDefault("DEBT_WEBHOOK_CACHE_USER_TTL", 15))}}) // Start HTTP server for property reload handler (without TLS) - jwtSecret := os.Getenv("ACCOUNT_API_JWT_SECRET") + jwtSecret := os.Getenv(controllers.EnvJwtSecret) + adminJwtSecret := os.Getenv(controllers.EnvAdminJwtSecret) reloadHandler := &controllers.PropertyReloadHandler{ AccountReconciler: accountReconciler, DBClient: dbClient, JwtSecret: jwtSecret, + AdminJwtSecret: adminJwtSecret, } go func() { setupLog.Info("starting property reload HTTP server", "port", 9444) diff --git a/controllers/pkg/utils/jwt.go b/controllers/pkg/utils/jwt.go index 37174b80d..865991020 100644 --- a/controllers/pkg/utils/jwt.go +++ b/controllers/pkg/utils/jwt.go @@ -30,6 +30,12 @@ type JwtUser struct { WorkspaceUID string `json:"workspaceUid,omitempty"` } +const ( + AdminJWTRequester = "sealos-admin" + AdminJWTIssuer = "sealos-admin" + AdminJWTAudience = "sealos-account-admin" +) + func NewJWTManager(secretKey string, tokenDuration time.Duration) *JWTManager { return &JWTManager{[]byte(secretKey), tokenDuration} } @@ -78,3 +84,64 @@ func (manager *JWTManager) ParseUser(token string) (*JwtUser, error) { } return &claims.JwtUser, nil } + +func (manager *JWTManager) GenerateAdminToken(user JwtUser) (string, error) { + now := time.Now() + claims := UserClaims{ + StandardClaims: jwt.StandardClaims{ + ExpiresAt: now.Add(manager.tokenDuration).Unix(), + IssuedAt: now.Unix(), + Issuer: AdminJWTIssuer, + Audience: AdminJWTAudience, + }, + JwtUser: user, + } + claims.JwtUser.Requester = AdminJWTRequester + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(manager.secretKey) +} + +func (manager *JWTManager) VerifyAdminToken(tokenString string) (*UserClaims, error) { + token, err := jwt.ParseWithClaims( + tokenString, + &UserClaims{}, + func(token *jwt.Token) (any, error) { + if token.Method == nil || token.Method.Alg() != jwt.SigningMethodHS256.Alg() { + return nil, errors.New("unexpected token signing method") + } + + return manager.secretKey, nil + }, + ) + if err != nil { + return nil, fmt.Errorf("invalid admin token: %w", err) + } + + claims, ok := token.Claims.(*UserClaims) + if !ok { + return nil, errors.New("invalid admin token claims") + } + if !claims.VerifyExpiresAt(time.Now().Unix(), true) { + return nil, errors.New("admin token expiration is required") + } + if !claims.VerifyIssuer(AdminJWTIssuer, true) { + return nil, errors.New("invalid admin token issuer") + } + if !claims.VerifyAudience(AdminJWTAudience, true) { + return nil, errors.New("invalid admin token audience") + } + if claims.JwtUser.Requester != AdminJWTRequester { + return nil, errors.New("user is not admin") + } + + return claims, nil +} + +func (manager *JWTManager) ParseAdminUser(token string) (*JwtUser, error) { + claims, err := manager.VerifyAdminToken(token) + if err != nil { + return nil, err + } + return &claims.JwtUser, nil +} diff --git a/controllers/pkg/utils/jwt_test.go b/controllers/pkg/utils/jwt_test.go new file mode 100644 index 000000000..eb35c30b0 --- /dev/null +++ b/controllers/pkg/utils/jwt_test.go @@ -0,0 +1,111 @@ +package utils + +import ( + "testing" + "time" + + "github.com/golang-jwt/jwt" +) + +func TestAdminTokenClaims(t *testing.T) { + manager := NewJWTManager("admin-secret", time.Hour) + token, err := manager.GenerateAdminToken(JwtUser{Requester: "ordinary-user"}) + if err != nil { + t.Fatalf("GenerateAdminToken() error = %v", err) + } + + user, err := manager.ParseAdminUser(token) + if err != nil { + t.Fatalf("ParseAdminUser() error = %v", err) + } + if user.Requester != AdminJWTRequester { + t.Fatalf("ParseAdminUser() requester = %q, want %q", user.Requester, AdminJWTRequester) + } +} + +func TestVerifyAdminTokenRejectsInvalidClaims(t *testing.T) { + manager := NewJWTManager("admin-secret", time.Hour) + now := time.Now().Unix() + + tests := []struct { + name string + method jwt.SigningMethod + claims jwt.StandardClaims + secret string + }{ + { + name: "ordinary claims", + claims: jwt.StandardClaims{ + ExpiresAt: now + 3600, + }, + secret: "admin-secret", + }, + { + name: "wrong issuer", + claims: jwt.StandardClaims{ + ExpiresAt: now + 3600, + Issuer: "account-api", + Audience: AdminJWTAudience, + }, + secret: "admin-secret", + }, + { + name: "wrong audience", + claims: jwt.StandardClaims{ + ExpiresAt: now + 3600, + Issuer: AdminJWTIssuer, + Audience: "account-api", + }, + secret: "admin-secret", + }, + { + name: "expired", + claims: jwt.StandardClaims{ + ExpiresAt: now - 1, + Issuer: AdminJWTIssuer, + Audience: AdminJWTAudience, + }, + secret: "admin-secret", + }, + { + name: "wrong secret", + claims: jwt.StandardClaims{ + ExpiresAt: now + 3600, + Issuer: AdminJWTIssuer, + Audience: AdminJWTAudience, + }, + secret: "api-secret", + }, + { + name: "wrong algorithm", + method: jwt.SigningMethodHS512, + claims: jwt.StandardClaims{ + ExpiresAt: now + 3600, + Issuer: AdminJWTIssuer, + Audience: AdminJWTAudience, + }, + secret: "admin-secret", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + method := tt.method + if method == nil { + method = jwt.SigningMethodHS256 + } + claims := UserClaims{ + StandardClaims: tt.claims, + JwtUser: JwtUser{Requester: AdminJWTRequester}, + } + token, err := jwt.NewWithClaims(method, claims).SignedString([]byte(tt.secret)) + if err != nil { + t.Fatalf("SignedString() error = %v", err) + } + + if _, err = manager.VerifyAdminToken(token); err == nil { + t.Fatal("VerifyAdminToken() error = nil, want error") + } + }) + } +} diff --git a/service/account/api/admin.go b/service/account/api/admin.go index 68db5dab7..085ee5914 100644 --- a/service/account/api/admin.go +++ b/service/account/api/admin.go @@ -13,6 +13,7 @@ import ( "github.com/google/uuid" "github.com/labring/sealos/controllers/pkg/pay" "github.com/labring/sealos/controllers/pkg/types" + "github.com/labring/sealos/controllers/pkg/utils" "github.com/labring/sealos/service/account/dao" "github.com/labring/sealos/service/account/helper" "github.com/sirupsen/logrus" @@ -373,7 +374,7 @@ func AdminGetUserRealNameInfo(c *gin.Context) { }) } -const AdminUserName = "sealos-admin" +const AdminUserName = utils.AdminJWTRequester func authenticateAdminRequest(c *gin.Context) error { tokenString := c.GetHeader("Authorization") @@ -381,9 +382,18 @@ func authenticateAdminRequest(c *gin.Context) error { return errors.New("null auth found") } token := strings.TrimPrefix(tokenString, "Bearer ") + + if dao.AdminJwtMgr != nil { + if _, err := dao.AdminJwtMgr.ParseAdminUser(token); err == nil { + return nil + } + } + + // Keep accepting tokens signed with the legacy account secret during the + // admin-secret migration. Remove this fallback after all callers migrate. user, err := dao.JwtMgr.ParseUser(token) if err != nil { - return fmt.Errorf("failed to parse user: %w", err) + return fmt.Errorf("failed to parse admin user: %w", err) } if user == nil { return errors.New("user not found") diff --git a/service/account/api/admin_auth_test.go b/service/account/api/admin_auth_test.go new file mode 100644 index 000000000..7c61b423b --- /dev/null +++ b/service/account/api/admin_auth_test.go @@ -0,0 +1,88 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/controllers/pkg/utils" + "github.com/labring/sealos/service/account/dao" +) + +func TestAuthenticateAdminRequestUsesDedicatedSecret(t *testing.T) { + gin.SetMode(gin.TestMode) + + originalAdminManager := dao.AdminJwtMgr + originalAPIManager := dao.JwtMgr + t.Cleanup(func() { + dao.AdminJwtMgr = originalAdminManager + dao.JwtMgr = originalAPIManager + }) + + adminManager := utils.NewJWTManager("admin-secret", time.Hour) + apiManager := utils.NewJWTManager("api-secret", time.Hour) + dao.AdminJwtMgr = adminManager + dao.JwtMgr = apiManager + + tests := []struct { + name string + manager *utils.JWTManager + requester string + adminToken bool + wantErr bool + }{ + { + name: "dedicated admin token", + manager: adminManager, + requester: AdminUserName, + adminToken: true, + }, + { + name: "admin key token without admin claims", + manager: adminManager, + requester: AdminUserName, + wantErr: true, + }, + { + name: "legacy admin token", + manager: apiManager, + requester: AdminUserName, + }, + { + name: "ordinary api token", + manager: apiManager, + requester: "user", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var token string + var err error + if tt.adminToken { + token, err = tt.manager.GenerateAdminToken(utils.JwtUser{Requester: tt.requester}) + } else { + token, err = tt.manager.GenerateToken(utils.JwtUser{Requester: tt.requester}) + } + if err != nil { + t.Fatalf("generate token: %v", err) + } + + req := httptest.NewRequestWithContext( + context.Background(), http.MethodGet, "/", nil, + ) + req.Header.Set("Authorization", "Bearer "+token) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = req + + err = authenticateAdminRequest(c) + if (err != nil) != tt.wantErr { + t.Fatalf("authenticateAdminRequest() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/service/account/api/admin_read.go b/service/account/api/admin_read.go new file mode 100644 index 000000000..727c8b8a5 --- /dev/null +++ b/service/account/api/admin_read.go @@ -0,0 +1,380 @@ +package api + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/account/dao" + "github.com/labring/sealos/service/account/helper" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +func adminReadPage(c *gin.Context) (int, int, error) { + pageIndex := 0 + pageSize := 10 + var err error + if raw := c.Query("pageIndex"); raw != "" { + pageIndex, err = strconv.Atoi(raw) + if err != nil || pageIndex < 0 { + return 0, 0, errors.New("pageIndex must be a non-negative integer") + } + } + if raw := c.Query("pageSize"); raw != "" { + pageSize, err = strconv.Atoi(raw) + if err != nil || pageSize <= 0 || pageSize > 100 { + return 0, 0, errors.New("pageSize must be between 1 and 100") + } + } + return pageIndex, pageSize, nil +} + +func adminReadTime(c *gin.Context, name string) (*time.Time, error) { + raw := c.Query(name) + if raw == "" { + return nil, nil + } + value, err := time.Parse(time.RFC3339, raw) + if err != nil { + return nil, fmt.Errorf("%s must be RFC3339: %w", name, err) + } + return &value, nil +} + +func adminReadUnauthorized(c *gin.Context, err error) { + logrus.WithError(err).Warn("admin read authentication failed") + c.JSON( + http.StatusUnauthorized, + helper.ErrorMessage{Error: "authentication failed"}, + ) +} + +func adminReadFailure(c *gin.Context, err error) { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, helper.ErrorMessage{Error: "resource not found"}) + return + } + logrus.WithError(err).Error("admin read request failed") + c.JSON(http.StatusInternalServerError, helper.ErrorMessage{Error: "internal server error"}) +} + +// AdminListUsers returns account users and their global account/provider data. +// @Summary List users for admin +// @Tags AdminRead +// @Produce json +// @Param pageIndex query int false "Zero-based page index" +// @Param pageSize query int false "Page size, 1-100" +// @Param id query string false "User ID" +// @Param username query string false "Password provider username" +// @Param phone query string false "Phone provider ID" +// @Param workspaceId query string false "Workspace ID" +// @Param workspaceName query string false "Workspace display name" +// @Success 200 {object} helper.AdminUserListResp +// @Router /admin/v1alpha1/users [get] +func AdminListUsers(c *gin.Context) { + if err := authenticateAdminRequest(c); err != nil { + adminReadUnauthorized(c, err) + return + } + pageIndex, pageSize, err := adminReadPage(c) + if err != nil { + c.JSON(http.StatusBadRequest, helper.ErrorMessage{Error: err.Error()}) + return + } + result, err := dao.DBClient.ListAdminUsers(helper.AdminUserListReq{ + PageIndex: pageIndex, PageSize: pageSize, + ID: c.Query("id"), Username: c.Query("username"), Phone: c.Query("phone"), + WorkspaceID: c.Query("workspaceId"), WorkspaceName: c.Query("workspaceName"), + }) + if err != nil { + adminReadFailure(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +// AdminGetUser returns the account-domain user detail aggregate. +// @Summary Get user detail for admin +// @Tags AdminRead +// @Produce json +// @Param id query string true "User ID" +// @Success 200 {object} helper.AdminUserDetail +// @Router /admin/v1alpha1/user [get] +func AdminGetUser(c *gin.Context) { + if err := authenticateAdminRequest(c); err != nil { + adminReadUnauthorized(c, err) + return + } + id := c.Query("id") + if id == "" { + c.JSON(http.StatusBadRequest, helper.ErrorMessage{Error: "id is required"}) + return + } + result, err := dao.DBClient.GetAdminUser(id) + if err != nil { + adminReadFailure(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +// AdminListUserRechargeRecords returns paginated payments and refund summaries. +// @Summary List user recharge records for admin +// @Tags AdminRead +// @Produce json +// @Param id query string true "User ID" +// @Param pageIndex query int false "Zero-based page index" +// @Param pageSize query int false "Page size, 1-100" +// @Success 200 {object} helper.AdminRechargeRecordsResp +// @Router /admin/v1alpha1/user/recharge-records [get] +func AdminListUserRechargeRecords(c *gin.Context) { + if err := authenticateAdminRequest(c); err != nil { + adminReadUnauthorized(c, err) + return + } + id := c.Query("id") + if id == "" { + c.JSON(http.StatusBadRequest, helper.ErrorMessage{Error: "id is required"}) + return + } + pageIndex, pageSize, err := adminReadPage(c) + if err != nil { + c.JSON(http.StatusBadRequest, helper.ErrorMessage{Error: err.Error()}) + return + } + result, err := dao.DBClient.ListAdminUserRechargeRecords(id, pageIndex, pageSize) + if err != nil { + adminReadFailure(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +// AdminListUserBalanceAdjustRecords returns admin balance adjustments for a user. +// @Summary List user balance adjustments for admin +// @Tags AdminRead +// @Produce json +// @Param id query string true "User ID" +// @Param pageIndex query int false "Zero-based page index" +// @Param pageSize query int false "Page size, 1-100" +// @Success 200 {object} helper.AdminBalanceAdjustRecordsResp +// @Router /admin/v1alpha1/user/balance-adjust-records [get] +func AdminListUserBalanceAdjustRecords(c *gin.Context) { + if err := authenticateAdminRequest(c); err != nil { + adminReadUnauthorized(c, err) + return + } + id := c.Query("id") + if id == "" { + c.JSON(http.StatusBadRequest, helper.ErrorMessage{Error: "id is required"}) + return + } + pageIndex, pageSize, err := adminReadPage(c) + if err != nil { + c.JSON(http.StatusBadRequest, helper.ErrorMessage{Error: err.Error()}) + return + } + result, err := dao.DBClient.ListAdminUserBalanceAdjustRecords(id, pageIndex, pageSize) + if err != nil { + adminReadFailure(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +// AdminListGiftCodes returns filtered gift codes. +// @Summary List gift codes for admin +// @Tags AdminRead +// @Produce json +// @Success 200 {object} helper.AdminGiftCodeListResp +// @Router /admin/v1alpha1/gift-codes [get] +func AdminListGiftCodes(c *gin.Context) { + if err := authenticateAdminRequest(c); err != nil { + adminReadUnauthorized(c, err) + return + } + pageIndex, pageSize, err := adminReadPage(c) + if err != nil { + c.JSON(http.StatusBadRequest, helper.ErrorMessage{Error: err.Error()}) + return + } + startTime, err := adminReadTime(c, "startTime") + if err != nil { + c.JSON(http.StatusBadRequest, helper.ErrorMessage{Error: err.Error()}) + return + } + endTime, err := adminReadTime(c, "endTime") + if err != nil { + c.JSON(http.StatusBadRequest, helper.ErrorMessage{Error: err.Error()}) + return + } + result, err := dao.DBClient.ListAdminGiftCodes(helper.AdminGiftCodeListReq{ + PageIndex: pageIndex, + PageSize: pageSize, + Status: c.Query("status"), + StartTime: startTime, + EndTime: endTime, + ID: c.Query("id"), + Code: c.Query("code"), + Comment: c.Query("comment"), + RechargeType: c.Query("rechargeType"), + }) + if err != nil { + adminReadFailure(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +// AdminListGiftCodeUsage returns gift codes redeemed by a user. +// @Summary List user gift code usage for admin +// @Tags AdminRead +// @Produce json +// @Param id query string true "User ID" +// @Success 200 {object} helper.AdminGiftCodeUsageResp +// @Router /admin/v1alpha1/gift-codes/usage [get] +func AdminListGiftCodeUsage(c *gin.Context) { + if err := authenticateAdminRequest(c); err != nil { + adminReadUnauthorized(c, err) + return + } + id := c.Query("id") + if id == "" { + c.JSON(http.StatusBadRequest, helper.ErrorMessage{Error: "id is required"}) + return + } + pageIndex, pageSize, err := adminReadPage(c) + if err != nil { + c.JSON(http.StatusBadRequest, helper.ErrorMessage{Error: err.Error()}) + return + } + result, err := dao.DBClient.ListAdminGiftCodeUsage(id, pageIndex, pageSize) + if err != nil { + adminReadFailure(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +// AdminListInvoices returns filtered invoices. +// @Summary List invoices for admin +// @Tags AdminRead +// @Produce json +// @Success 200 {object} helper.AdminInvoiceListResp +// @Router /admin/v1alpha1/invoices [get] +func AdminListInvoices(c *gin.Context) { + if err := authenticateAdminRequest(c); err != nil { + adminReadUnauthorized(c, err) + return + } + pageIndex, pageSize, err := adminReadPage(c) + if err != nil { + c.JSON(http.StatusBadRequest, helper.ErrorMessage{Error: err.Error()}) + return + } + result, err := dao.DBClient.ListAdminInvoices( + helper.AdminInvoiceListReq{ + PageIndex: pageIndex, + PageSize: pageSize, + Status: c.Query("status"), + CompanyName: c.Query("companyName"), + }, + ) + if err != nil { + adminReadFailure(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +// AdminGetInvoice returns a single invoice. +// @Summary Get invoice for admin +// @Tags AdminRead +// @Produce json +// @Param id query string true "Invoice ID" +// @Success 200 {object} helper.AdminInvoice +// @Router /admin/v1alpha1/invoice [get] +func AdminGetInvoice(c *gin.Context) { + if err := authenticateAdminRequest(c); err != nil { + adminReadUnauthorized(c, err) + return + } + id := c.Query("id") + if id == "" { + c.JSON(http.StatusBadRequest, helper.ErrorMessage{Error: "id is required"}) + return + } + result, err := dao.DBClient.GetAdminInvoice(id) + if err != nil { + adminReadFailure(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +// AdminGetRefundStatus returns refund state for a payment or trade number. +// @Summary Get refund status for admin +// @Tags AdminRead +// @Produce json +// @Param id query string true "Payment ID or trade number" +// @Success 200 {object} helper.AdminRefundStatusResp +// @Router /admin/v1alpha1/refund-status [get] +func AdminGetRefundStatus(c *gin.Context) { + if err := authenticateAdminRequest(c); err != nil { + adminReadUnauthorized(c, err) + return + } + id := c.Query("id") + if id == "" { + c.JSON(http.StatusBadRequest, helper.ErrorMessage{Error: "id is required"}) + return + } + result, err := dao.DBClient.GetAdminRefundStatus(id) + if err != nil { + adminReadFailure(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +// AdminGetRechargeGiftPolicy returns the account recharge discount policy. +// @Summary Get recharge gift policy for admin +// @Tags AdminRead +// @Produce json +// @Success 200 {object} helper.AdminRechargeGiftPolicy +// @Router /admin/v1alpha1/recharge-gift-policy [get] +func AdminGetRechargeGiftPolicy(c *gin.Context) { + if err := authenticateAdminRequest(c); err != nil { + adminReadUnauthorized(c, err) + return + } + result, err := dao.DBClient.GetAdminRechargeGiftPolicy() + if err != nil { + adminReadFailure(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +// AdminListRegions returns global region records. +// @Summary List regions for admin +// @Tags AdminRead +// @Produce json +// @Success 200 {array} helper.AdminRegion +// @Router /admin/v1alpha1/regions [get] +func AdminListRegions(c *gin.Context) { + if err := authenticateAdminRequest(c); err != nil { + adminReadUnauthorized(c, err) + return + } + result, err := dao.DBClient.ListAdminRegions() + if err != nil { + adminReadFailure(c, err) + return + } + c.JSON(http.StatusOK, result) +} diff --git a/service/account/api/admin_read_test.go b/service/account/api/admin_read_test.go new file mode 100644 index 000000000..16aba3c1d --- /dev/null +++ b/service/account/api/admin_read_test.go @@ -0,0 +1,115 @@ +package api + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +func TestAdminReadPage(t *testing.T) { + tests := []struct { + name string + query string + pageIndex int + pageSize int + wantErr bool + }{ + {name: "defaults", query: "/", pageIndex: 0, pageSize: 10}, + {name: "valid", query: "/?pageIndex=3&pageSize=25", pageIndex: 3, pageSize: 25}, + {name: "invalid page", query: "/?pageIndex=-1", wantErr: true}, + {name: "invalid size", query: "/?pageSize=101", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequestWithContext(context.Background(), "GET", tt.query, nil) + pageIndex, pageSize, err := adminReadPage(c) + if (err != nil) != tt.wantErr { + t.Fatalf("adminReadPage() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if pageIndex != tt.pageIndex || + pageSize != tt.pageSize { + t.Fatalf( + "adminReadPage() = %d, %d; want %d, %d", + pageIndex, + pageSize, + tt.pageIndex, + tt.pageSize, + ) + } + }) + } +} + +func TestAdminReadTime(t *testing.T) { + want := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequestWithContext( + context.Background(), + "GET", + "/?startTime="+want.Format(time.RFC3339), + nil, + ) + got, err := adminReadTime(c, "startTime") + if err != nil { + t.Fatalf("adminReadTime() error = %v", err) + } + if got == nil || !got.Equal(want) { + t.Fatalf("adminReadTime() = %v, want %v", got, want) + } + + invalidContext, _ := gin.CreateTestContext(httptest.NewRecorder()) + invalidContext.Request = httptest.NewRequestWithContext( + context.Background(), + "GET", + "/?startTime=invalid", + nil, + ) + if _, err := adminReadTime(invalidContext, "startTime"); err == nil { + t.Fatal("adminReadTime() expected invalid time error") + } +} + +func TestAdminReadFailureHidesInternalError(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + adminReadFailure(c, errors.New("database schema details")) + + if recorder.Code != http.StatusInternalServerError { + t.Fatalf( + "adminReadFailure() status = %d, want %d", + recorder.Code, + http.StatusInternalServerError, + ) + } + if strings.Contains(recorder.Body.String(), "database schema details") { + t.Fatalf("adminReadFailure() exposed internal error: %s", recorder.Body.String()) + } +} + +func TestAdminReadUnauthorizedHidesAuthenticationError(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + adminReadUnauthorized(c, errors.New("invalid admin token: signing key details")) + + if recorder.Code != http.StatusUnauthorized { + t.Fatalf( + "adminReadUnauthorized() status = %d, want %d", + recorder.Code, + http.StatusUnauthorized, + ) + } + if strings.Contains(recorder.Body.String(), "signing key details") { + t.Fatalf("adminReadUnauthorized() exposed authentication error: %s", recorder.Body.String()) + } +} diff --git a/service/account/dao/admin_read.go b/service/account/dao/admin_read.go new file mode 100644 index 000000000..7b77b31fa --- /dev/null +++ b/service/account/dao/admin_read.go @@ -0,0 +1,889 @@ +package dao + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/labring/sealos/controllers/pkg/types" + "github.com/labring/sealos/service/account/helper" + "github.com/lib/pq" + "gorm.io/gorm" +) + +type adminUserAccountType struct { + UserType string `gorm:"column:userType"` + ProductSeries pq.StringArray `gorm:"column:productSeries"` +} + +type adminWorkspaceRow struct { + UID uuid.UUID `gorm:"column:uid"` + ID string `gorm:"column:id"` + DisplayName string `gorm:"column:displayName"` + Role string `gorm:"column:role"` + Status string `gorm:"column:status"` + IsPrivate bool `gorm:"column:isPrivate"` +} + +type adminPaymentRow struct { + ID string `gorm:"column:id"` + TradeNo string `gorm:"column:trade_no"` + CreatedAt time.Time `gorm:"column:created_at"` + Method string `gorm:"column:method"` + Status string `gorm:"column:status"` + Amount int64 `gorm:"column:amount"` + Gift *int64 `gorm:"column:gift"` + Type string `gorm:"column:type"` + ChargeSource string `gorm:"column:charge_source"` +} + +type adminAccountTransactionRow struct { + ID uuid.UUID `gorm:"column:id"` + CreatedAt time.Time `gorm:"column:created_at"` + Type string `gorm:"column:type"` + Balance int64 `gorm:"column:balance"` + BalanceBefore *int64 `gorm:"column:balance_before"` + Message *string `gorm:"column:message"` +} + +type adminGiftCodeRow struct { + ID uuid.UUID `gorm:"column:id"` + Code string `gorm:"column:code"` + Used bool `gorm:"column:used"` + CreditAmount int64 `gorm:"column:creditAmount"` + UsedBy *uuid.UUID `gorm:"column:usedBy"` + UsedByUserID string `gorm:"column:usedByUserID"` + UsedAt *time.Time `gorm:"column:usedAt"` + CreatedAt time.Time `gorm:"column:createdAt"` + ExpiredAt *time.Time `gorm:"column:expiredAt"` + Comment string `gorm:"column:comment"` + CreatedBy string `gorm:"column:createdBy"` + CreatedByID string `gorm:"column:createdByID"` + CreatedByReal *string `gorm:"column:createdByReal"` + RechargeType string `gorm:"column:rechargeType"` +} + +type adminGiftCodeUsageRow struct { + ID uuid.UUID `gorm:"column:id"` + Code string `gorm:"column:code"` + CreditAmount int64 `gorm:"column:creditAmount"` + UsedAt *time.Time `gorm:"column:usedAt"` + RechargeType string `gorm:"column:rechargeType"` + Comment string `gorm:"column:comment"` +} + +type adminInvoiceRow struct { + ID string `gorm:"column:id"` + UserID string `gorm:"column:user_id"` + CreatedAt *time.Time `gorm:"column:created_at"` + UpdatedAt *time.Time `gorm:"column:updated_at"` + Detail string `gorm:"column:detail"` + Remark string `gorm:"column:remark"` + TotalAmount int64 `gorm:"column:total_amount"` + Status string `gorm:"column:status"` +} + +func adminPage(pageIndex, pageSize int, total int64) helper.AdminPage { + pages := 0 + if total > 0 { + pages = int((total + int64(pageSize) - 1) / int64(pageSize)) + } + return helper.AdminPage{ + PageIndex: pageIndex, + PageSize: pageSize, + TotalItems: total, + TotalPages: pages, + } +} + +func adminUserQuery(db *gorm.DB, req helper.AdminUserListReq) *gorm.DB { + query := db.Model(&types.User{}) + if req.ID != "" { + query = query.Where(`"User"."id" = ?`, req.ID) + } + if req.Username != "" { + query = query.Where( + `EXISTS (SELECT 1 FROM "OauthProvider" op WHERE op."userUid" = "User".uid AND op."providerType" = ? AND op."providerId" = ?)`, + types.OauthProviderTypePassword, + req.Username, + ) + } + if req.Phone != "" { + query = query.Where( + `EXISTS (SELECT 1 FROM "OauthProvider" op WHERE op."userUid" = "User".uid AND op."providerType" = ? AND op."providerId" = ?)`, + types.OauthProviderTypePhone, + req.Phone, + ) + } + return query +} + +func (g *Cockroach) userUIDsByWorkspace(id, displayName string) ([]uuid.UUID, error) { + query := g.ck.GetLocalDB().Table("UserCr"). + Select(`DISTINCT "UserCr"."userUid"`). + Joins(`JOIN "UserWorkspace" ON "UserWorkspace"."userCrUid" = "UserCr".uid`). + Joins(`JOIN "Workspace" ON "Workspace".uid = "UserWorkspace"."workspaceUid"`) + if id != "" { + query = query.Where(`"Workspace"."id" = ?`, id) + } + if displayName != "" { + query = query.Where(`"Workspace"."displayName" = ?`, displayName) + } + var rows []struct { + UserUID uuid.UUID `gorm:"column:userUid"` + } + if err := query.Scan(&rows).Error; err != nil { + return nil, fmt.Errorf("failed to query workspace users: %w", err) + } + result := make([]uuid.UUID, 0, len(rows)) + for i := range rows { + result = append(result, rows[i].UserUID) + } + return result, nil +} + +func (g *Cockroach) loadAdminUsers(users []types.User) ([]helper.AdminUser, error) { + if len(users) == 0 { + return []helper.AdminUser{}, nil + } + + userUIDs := make([]uuid.UUID, len(users)) + for i := range users { + userUIDs[i] = users[i].UID + } + db := g.ck.GetGlobalDB() + + var providers []types.OauthProvider + if err := db.Where(`"userUid" IN ?`, userUIDs).Find(&providers).Error; err != nil { + return nil, fmt.Errorf("failed to list oauth providers for admin users: %w", err) + } + var alertAccounts []types.UserAlertNotificationAccount + if err := db.Where(`"user_uid" IN ? AND "is_enabled" = ?`, userUIDs, true). + Find(&alertAccounts). + Error; err != nil { + return nil, fmt.Errorf( + "failed to list alert notification accounts for admin users: %w", + err, + ) + } + var accounts []types.Account + if err := db.Where(`"userUid" IN ?`, userUIDs).Find(&accounts).Error; err != nil { + return nil, fmt.Errorf("failed to list accounts for admin users: %w", err) + } + + providersByUser := groupAdminUserProviders(providers, alertAccounts) + accountsByUser := make(map[uuid.UUID]*types.Account, len(accounts)) + for i := range accounts { + accountsByUser[accounts[i].UserUID] = &accounts[i] + } + result := make([]helper.AdminUser, len(users)) + for i := range users { + result[i] = formatAdminUser( + &users[i], + providersByUser[users[i].UID], + accountsByUser[users[i].UID], + ) + } + return result, nil +} + +func groupAdminUserProviders( + providers []types.OauthProvider, + alertAccounts []types.UserAlertNotificationAccount, +) map[uuid.UUID][]types.OauthProvider { + providersByUser := make(map[uuid.UUID][]types.OauthProvider) + existingProviders := make(map[uuid.UUID]map[string]struct{}) + for i := range providers { + provider := providers[i] + providersByUser[provider.UserUID] = append(providersByUser[provider.UserUID], provider) + if existingProviders[provider.UserUID] == nil { + existingProviders[provider.UserUID] = make(map[string]struct{}) + } + existingProviders[provider.UserUID][adminProviderKey(provider.ProviderType, provider.ProviderID)] = struct{}{} + } + for i := range alertAccounts { + account := alertAccounts[i] + if account.ProviderType != types.OauthProviderTypeEmail && + account.ProviderType != types.OauthProviderTypePhone { + continue + } + if existingProviders[account.UserUID] == nil { + existingProviders[account.UserUID] = make(map[string]struct{}) + } + key := adminProviderKey(account.ProviderType, account.ProviderID) + if _, exists := existingProviders[account.UserUID][key]; exists { + continue + } + providersByUser[account.UserUID] = append( + providersByUser[account.UserUID], + types.OauthProvider{ + UserUID: account.UserUID, + ProviderType: account.ProviderType, + ProviderID: account.ProviderID, + CreatedAt: account.CreatedAt, + UpdatedAt: account.UpdatedAt, + }, + ) + existingProviders[account.UserUID][key] = struct{}{} + } + return providersByUser +} + +func adminProviderKey(providerType types.OauthProviderType, providerID string) string { + return string(providerType) + "_" + providerID +} + +func formatAdminUser( + user *types.User, + providers []types.OauthProvider, + account *types.Account, +) helper.AdminUser { + item := helper.AdminUser{ + UID: user.UID, + ID: user.ID, + Nickname: user.Nickname, + Status: string(user.Status), + BillingStatus: "no_account", + SourceType: "OTHER", + SourceProviderTypes: make([]string, 0, len(providers)), + } + providerTypes := make([]string, 0, len(providers)) + for i := range providers { + providerType := string(providers[i].ProviderType) + providerTypes = append(providerTypes, providerType) + switch providers[i].ProviderType { + case types.OauthProviderTypePassword: + if item.Username == "" { + item.Username = providers[i].ProviderID + } + case types.OauthProviderTypePhone: + item.Phone = providers[i].ProviderID + case types.OauthProviderTypeEmail: + item.Email = providers[i].ProviderID + } + } + if item.Username != "" { + item.SourceType = "ADMIN_CREATED" + item.SourceProviderTypes = []string{string(types.OauthProviderTypePassword)} + } else { + for _, providerType := range []string{"GITHUB", "WECHAT", "GOOGLE", "OAUTH2"} { + for _, actual := range providerTypes { + if providerType == actual { + item.SourceType = "OAUTH_LOGIN" + item.SourceProviderTypes = append(item.SourceProviderTypes, actual) + } + } + } + if item.SourceType == "OTHER" { + item.SourceProviderTypes = providerTypes + } + } + if account != nil { + balance := account.Balance + deduction := account.DeductionBalance + available := balance - deduction + item.Balance = &balance + item.DeductionBalance = &deduction + item.AvailableBalance = &available + item.BillingStatus = "normal" + if available < 0 { + item.BillingStatus = "insufficient" + } + } + return item +} + +func (g *Cockroach) adminUserWorkspaces(userUID uuid.UUID) ([]helper.AdminWorkspace, error) { + var rows []adminWorkspaceRow + err := g.ck.GetLocalDB().Table("UserCr"). + Select(`"Workspace".uid, "Workspace".id, "Workspace"."displayName", "UserWorkspace".role, "UserWorkspace".status, "UserWorkspace"."isPrivate"`). + Joins(`JOIN "UserWorkspace" ON "UserWorkspace"."userCrUid" = "UserCr".uid`). + Joins(`JOIN "Workspace" ON "Workspace".uid = "UserWorkspace"."workspaceUid"`). + Where(`"UserCr"."userUid" = ?`, userUID). + Order(`"Workspace".id ASC`). + Find(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to get user workspaces: %w", err) + } + result := make([]helper.AdminWorkspace, len(rows)) + for i := range rows { + result[i] = helper.AdminWorkspace{ + UID: rows[i].UID, ID: rows[i].ID, DisplayName: rows[i].DisplayName, + Role: rows[i].Role, Status: rows[i].Status, IsPrivate: rows[i].IsPrivate, + } + } + return result, nil +} + +func (g *Cockroach) ListAdminUsers(req helper.AdminUserListReq) (helper.AdminUserListResp, error) { + pageIndex, pageSize := normalizeAdminPage(req.PageIndex, req.PageSize) + var workspaceUIDs []uuid.UUID + if req.WorkspaceID != "" || req.WorkspaceName != "" { + var err error + workspaceUIDs, err = g.userUIDsByWorkspace(req.WorkspaceID, req.WorkspaceName) + if err != nil { + return helper.AdminUserListResp{}, err + } + } + query := adminUserQuery(g.ck.GetGlobalDB(), req) + if req.WorkspaceID != "" || req.WorkspaceName != "" { + if len(workspaceUIDs) == 0 { + return helper.AdminUserListResp{ + AdminPage: adminPage(pageIndex, pageSize, 0), + List: []helper.AdminUser{}, + }, nil + } + query = query.Where(`"User".uid IN ?`, workspaceUIDs) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return helper.AdminUserListResp{}, fmt.Errorf("failed to count admin users: %w", err) + } + var users []types.User + if err := query.Order(`"User"."createdAt" DESC`). + Offset(pageIndex * pageSize). + Limit(pageSize). + Find(&users). + Error; err != nil { + return helper.AdminUserListResp{}, fmt.Errorf("failed to list admin users: %w", err) + } + list, err := g.loadAdminUsers(users) + if err != nil { + return helper.AdminUserListResp{}, err + } + return helper.AdminUserListResp{ + AdminPage: adminPage(pageIndex, pageSize, total), + List: list, + }, nil +} + +func (g *Cockroach) GetAdminUser(id string) (*helper.AdminUserDetail, error) { + var user types.User + if err := g.ck.GetGlobalDB().Where(`id = ?`, id).First(&user).Error; err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + providers, err := g.ck.GetUserOauthProvider(&types.UserQueryOpts{UID: user.UID}) + if err != nil { + return nil, err + } + account, err := g.GetAccount(types.UserQueryOpts{UID: user.UID, IgnoreEmpty: true}) + if err != nil { + return nil, err + } + item := formatAdminUser(&user, providers, account) + var realName, idCard string + userRealName, realNameErr := g.ck.GetUserRealNameInfoByUserID(user.ID) + if realNameErr != nil && !errors.Is(realNameErr, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("failed to get user real name info: %w", realNameErr) + } + if userRealName != nil { + if userRealName.RealName != nil { + realName = *userRealName.RealName + } + if userRealName.IDCard != nil { + idCard = *userRealName.IDCard + } + } + var accountType adminUserAccountType + if err := g.ck.GetGlobalDB(). + Table("UserAccountType"). + Where(`"userUid" = ?`, user.UID). + First(&accountType). + Error; err != nil && + !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("failed to get user account type: %w", err) + } + workspaces, err := g.adminUserWorkspaces(user.UID) + if err != nil { + return nil, err + } + rechargeAmount, err := g.adminUserRechargeAmount(user.UID) + if err != nil { + return nil, err + } + if accountType.UserType != "" { + itemType := accountType.UserType + series := make([]string, len(accountType.ProductSeries)) + copy(series, accountType.ProductSeries) + return &helper.AdminUserDetail{ + AdminUser: item, UserType: itemType, ProductSeries: series, + RealName: realName, IDCard: idCard, RechargeAmount: rechargeAmount, + Workspaces: workspaces, + }, nil + } + return &helper.AdminUserDetail{ + AdminUser: item, UserType: "EXTERNAL_USER", ProductSeries: []string{}, + RealName: realName, IDCard: idCard, + RechargeAmount: rechargeAmount, + Workspaces: workspaces, + }, nil +} + +func (g *Cockroach) adminUserRechargeAmount(userUID uuid.UUID) (int64, error) { + var amount int64 + if err := g.ck.GetGlobalDB().Model(&types.Payment{}). + Where(`"userUid" = ? AND "invoiced_at" = ?`, userUID, true). + Select(`COALESCE(SUM(amount), 0)`).Scan(&amount).Error; err != nil { + return 0, fmt.Errorf("failed to get recharge amount: %w", err) + } + return amount, nil +} + +func normalizeAdminPage(pageIndex, pageSize int) (int, int) { + if pageIndex < 0 { + pageIndex = 0 + } + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 100 { + pageSize = 100 + } + return pageIndex, pageSize +} + +func (g *Cockroach) resolveAdminUserID(id string) (uuid.UUID, error) { + var user types.User + if err := g.ck.GetGlobalDB().Where(`id = ?`, id).First(&user).Error; err != nil { + return uuid.Nil, fmt.Errorf("failed to get user: %w", err) + } + return user.UID, nil +} + +func (g *Cockroach) ListAdminUserRechargeRecords( + id string, + pageIndex, pageSize int, +) (helper.AdminRechargeRecordsResp, error) { + userUID, err := g.resolveAdminUserID(id) + if err != nil { + return helper.AdminRechargeRecordsResp{}, err + } + pageIndex, pageSize = normalizeAdminPage(pageIndex, pageSize) + query := g.ck.GetGlobalDB().Table("Payment").Where(`"userUid" = ?`, userUID) + var total int64 + if err := query.Count(&total).Error; err != nil { + return helper.AdminRechargeRecordsResp{}, fmt.Errorf( + "failed to count recharge records: %w", + err, + ) + } + var rows []adminPaymentRow + if err := query.Select(`id, trade_no, created_at, method, status, amount, gift, type, charge_source`). + Order(`created_at DESC`). + Offset(pageIndex * pageSize). + Limit(pageSize). + Scan(&rows). + Error; err != nil { + return helper.AdminRechargeRecordsResp{}, fmt.Errorf( + "failed to list recharge records: %w", + err, + ) + } + tradeNos := make([]string, 0, len(rows)) + for i := range rows { + if tradeNo := normalizeAdminTradeNo(rows[i].TradeNo); tradeNo != "" { + tradeNos = append(tradeNos, tradeNo) + } + } + refunds := map[string][]types.PaymentRefund{} + if len(tradeNos) > 0 { + var refundRows []types.PaymentRefund + if err := g.ck.GetGlobalDB(). + Where(`trade_no IN ?`, tradeNos). + Order(`created_at DESC`). + Find(&refundRows). + Error; err != nil { + return helper.AdminRechargeRecordsResp{}, fmt.Errorf( + "failed to list recharge refunds: %w", + err, + ) + } + for i := range refundRows { + key := normalizeAdminTradeNo(refundRows[i].TradeNo) + refunds[key] = append(refunds[key], refundRows[i]) + } + } + list := make([]helper.AdminRechargeRecord, len(rows)) + for i := range rows { + refundList := refunds[normalizeAdminTradeNo(rows[i].TradeNo)] + item := helper.AdminRechargeRecord{ + ID: rows[i].ID, + TradeNo: rows[i].TradeNo, + CreatedAt: rows[i].CreatedAt, + Method: rows[i].Method, + Status: rows[i].Status, + Amount: rows[i].Amount, + Gift: rows[i].Gift, + Type: rows[i].Type, + ChargeSource: rows[i].ChargeSource, + RefundCount: len(refundList), + Refunded: strings.EqualFold(rows[i].Status, "REFUNDED") || len(refundList) > 0, + } + if len(refundList) > 0 { + item.LatestRefund = adminRefund(&refundList[0]) + } + list[i] = item + } + return helper.AdminRechargeRecordsResp{ + AdminPage: adminPage(pageIndex, pageSize, total), + List: list, + }, nil +} + +func normalizeAdminTradeNo(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if len(value) == 32 { + value = value[:8] + "-" + value[8:12] + "-" + value[12:16] + "-" + value[16:20] + "-" + value[20:] + } + if _, err := uuid.Parse(value); err != nil { + return "" + } + return value +} + +func adminRefund(refund *types.PaymentRefund) *helper.AdminRefund { + return &helper.AdminRefund{ + ID: refund.ID, + RefundNo: refund.RefundNo, + RefundAmount: refund.RefundAmount, + DeductAmount: refund.DeductAmount, + CreatedAt: refund.CreatedAt, + RefundReason: refund.RefundReason, + } +} + +func (g *Cockroach) ListAdminUserBalanceAdjustRecords( + id string, + pageIndex, pageSize int, +) (helper.AdminBalanceAdjustRecordsResp, error) { + userUID, err := g.resolveAdminUserID(id) + if err != nil { + return helper.AdminBalanceAdjustRecordsResp{}, err + } + pageIndex, pageSize = normalizeAdminPage(pageIndex, pageSize) + query := g.ck.GetGlobalDB(). + Table("AccountTransaction"). + Where(`"userUid" = ? AND "type" = ?`, userUID, "AdminRecharge") + var total int64 + if err := query.Count(&total).Error; err != nil { + return helper.AdminBalanceAdjustRecordsResp{}, fmt.Errorf( + "failed to count balance adjustment records: %w", + err, + ) + } + var rows []adminAccountTransactionRow + if err := query.Select(`id, created_at, type, balance, balance_before, message`). + Order(`created_at DESC`). + Offset(pageIndex * pageSize). + Limit(pageSize). + Scan(&rows). + Error; err != nil { + return helper.AdminBalanceAdjustRecordsResp{}, fmt.Errorf( + "failed to list balance adjustment records: %w", + err, + ) + } + list := make([]helper.AdminBalanceAdjustRecord, len(rows)) + for i := range rows { + var amount *int64 + if rows[i].BalanceBefore != nil { + value := rows[i].Balance - *rows[i].BalanceBefore + amount = &value + } + message := "" + if rows[i].Message != nil { + message = *rows[i].Message + } + list[i] = helper.AdminBalanceAdjustRecord{ + ID: rows[i].ID, + CreatedAt: rows[i].CreatedAt, + Type: rows[i].Type, + Amount: amount, + BalanceBefore: rows[i].BalanceBefore, + BalanceAfter: rows[i].Balance, + Message: message, + } + } + return helper.AdminBalanceAdjustRecordsResp{ + AdminPage: adminPage(pageIndex, pageSize, total), + List: list, + }, nil +} + +func (g *Cockroach) ListAdminGiftCodes( + req helper.AdminGiftCodeListReq, +) (helper.AdminGiftCodeListResp, error) { + pageIndex, pageSize := normalizeAdminPage(req.PageIndex, req.PageSize) + query := g.ck.GetGlobalDB().Table("GiftCode"). + Select(`"GiftCode"."id", "GiftCode"."code", "GiftCode"."used", "GiftCode"."creditAmount", "GiftCode"."usedBy", used_user."id" AS "usedByUserID", "GiftCode"."usedAt", "GiftCode"."createdAt", "GiftCode"."expiredAt", "GiftCode"."comment", creator."id" AS "createdByID", creator."nickname" AS "createdBy", creator_real."realName" AS "createdByReal", "GiftCodeCreation"."rechargeType"`). + Joins(`LEFT JOIN "GiftCodeCreation" ON "GiftCodeCreation"."giftCodeId" = "GiftCode"."id"`). + Joins(`LEFT JOIN "User" creator ON creator."uid" = "GiftCodeCreation"."createdByUserUid"`). + Joins(`LEFT JOIN "UserRealNameInfo" creator_real ON creator_real."userUid" = creator."uid"`). + Joins(`LEFT JOIN "User" used_user ON used_user."uid" = "GiftCode"."usedBy"`) + if req.ID != "" { + query = query.Where(`CAST("GiftCode"."id" AS STRING) = ?`, req.ID) + } + if req.Code != "" { + query = query.Where(`"GiftCode"."code" LIKE ?`, "%"+req.Code+"%") + } + if req.Comment != "" { + query = query.Where(`"GiftCode"."comment" LIKE ?`, "%"+req.Comment+"%") + } + switch req.Status { + case "used": + query = query.Where(`"GiftCode"."used" = ?`, true) + case "unused": + query = query.Where(`"GiftCode"."used" = ?`, false) + } + if req.StartTime != nil { + query = query.Where(`"GiftCode"."createdAt" >= ?`, *req.StartTime) + } + if req.EndTime != nil { + query = query.Where(`"GiftCode"."createdAt" <= ?`, *req.EndTime) + } + if req.RechargeType != "" { + query = query.Where(`"GiftCodeCreation"."rechargeType" = ?`, req.RechargeType) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return helper.AdminGiftCodeListResp{}, fmt.Errorf("failed to count gift codes: %w", err) + } + var rows []adminGiftCodeRow + if err := query.Order(`"GiftCode"."createdAt" DESC`). + Offset(pageIndex * pageSize). + Limit(pageSize). + Scan(&rows). + Error; err != nil { + return helper.AdminGiftCodeListResp{}, fmt.Errorf("failed to list gift codes: %w", err) + } + list := make([]helper.AdminGiftCode, len(rows)) + for i := range rows { + createdBy := rows[i].CreatedByReal + if createdBy == nil || *createdBy == "" { + createdBy = &rows[i].CreatedBy + } + list[i] = helper.AdminGiftCode{ + ID: rows[i].ID, + Code: rows[i].Code, + Used: rows[i].Used, + CreditAmount: rows[i].CreditAmount, + UsedBy: rows[i].UsedBy, + UsedAt: rows[i].UsedAt, + CreatedAt: rows[i].CreatedAt, + ExpiredAt: rows[i].ExpiredAt, + Comment: rows[i].Comment, + CreatedBy: valueOrEmpty(createdBy), + CreatedByID: rows[i].CreatedByID, + RechargeType: rows[i].RechargeType, + } + } + return helper.AdminGiftCodeListResp{ + AdminPage: adminPage(pageIndex, pageSize, total), + List: list, + }, nil +} + +func valueOrEmpty(value *string) string { + if value == nil { + return "" + } + return *value +} + +func (g *Cockroach) ListAdminGiftCodeUsage( + id string, + pageIndex, pageSize int, +) (helper.AdminGiftCodeUsageResp, error) { + userUID, err := g.resolveAdminUserID(id) + if err != nil { + return helper.AdminGiftCodeUsageResp{}, err + } + pageIndex, pageSize = normalizeAdminPage(pageIndex, pageSize) + query := g.ck.GetGlobalDB().Table("GiftCode"). + Select(`"GiftCode"."id", "GiftCode"."code", "GiftCode"."creditAmount", "GiftCode"."usedAt", "GiftCodeCreation"."rechargeType", "GiftCode"."comment"`). + Joins(`LEFT JOIN "GiftCodeCreation" ON "GiftCodeCreation"."giftCodeId" = "GiftCode"."id"`). + Where(`"GiftCode"."used" = ? AND "GiftCode"."usedBy" = ?`, true, userUID) + var total int64 + if err := query.Count(&total).Error; err != nil { + return helper.AdminGiftCodeUsageResp{}, fmt.Errorf( + "failed to count gift code usage: %w", + err, + ) + } + var rows []adminGiftCodeUsageRow + if err := query.Order(`"GiftCode"."usedAt" DESC`). + Offset(pageIndex * pageSize). + Limit(pageSize). + Scan(&rows). + Error; err != nil { + return helper.AdminGiftCodeUsageResp{}, fmt.Errorf( + "failed to list gift code usage: %w", + err, + ) + } + list := make([]helper.AdminGiftCodeUsage, len(rows)) + for i := range rows { + list[i] = helper.AdminGiftCodeUsage{ + ID: rows[i].ID, + Code: rows[i].Code, + CreditAmount: rows[i].CreditAmount, + UsedAt: rows[i].UsedAt, + RechargeType: rows[i].RechargeType, + Comment: rows[i].Comment, + } + } + return helper.AdminGiftCodeUsageResp{ + AdminPage: adminPage(pageIndex, pageSize, total), + List: list, + }, nil +} + +func (g *Cockroach) ListAdminInvoices( + req helper.AdminInvoiceListReq, +) (helper.AdminInvoiceListResp, error) { + pageIndex, pageSize := normalizeAdminPage(req.PageIndex, req.PageSize) + query := g.ck.GetGlobalDB().Table("Invoice") + if req.Status != "" { + query = query.Where(`status = ?`, req.Status) + } + if req.CompanyName != "" { + query = query.Where(`detail LIKE ?`, "%"+req.CompanyName+"%") + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return helper.AdminInvoiceListResp{}, fmt.Errorf("failed to count invoices: %w", err) + } + var rows []adminInvoiceRow + if err := query.Order(`created_at DESC`). + Offset(pageIndex * pageSize). + Limit(pageSize). + Find(&rows). + Error; err != nil { + return helper.AdminInvoiceListResp{}, fmt.Errorf("failed to list invoices: %w", err) + } + return helper.AdminInvoiceListResp{ + AdminPage: adminPage(pageIndex, pageSize, total), + List: mapAdminInvoices(rows), + }, nil +} + +func mapAdminInvoices(rows []adminInvoiceRow) []helper.AdminInvoice { + list := make([]helper.AdminInvoice, len(rows)) + for i := range rows { + list[i] = helper.AdminInvoice{ + ID: rows[i].ID, + UserID: rows[i].UserID, + CreatedAt: rows[i].CreatedAt, + UpdatedAt: rows[i].UpdatedAt, + Detail: rows[i].Detail, + Remark: rows[i].Remark, + TotalAmount: rows[i].TotalAmount, + Status: rows[i].Status, + } + } + return list +} + +func (g *Cockroach) GetAdminInvoice(id string) (*helper.AdminInvoice, error) { + var row adminInvoiceRow + if err := g.ck.GetGlobalDB(). + Table("Invoice"). + Where(`id = ?`, id). + First(&row). + Error; err != nil { + return nil, fmt.Errorf("failed to get invoice: %w", err) + } + list := mapAdminInvoices([]adminInvoiceRow{row}) + return &list[0], nil +} + +func (g *Cockroach) GetAdminRefundStatus(id string) (helper.AdminRefundStatusResp, error) { + var order types.PaymentOrder + err := g.ck.GetGlobalDB().Where(`id = ?`, id).First(&order).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + err = g.ck.GetGlobalDB().Where(`trade_no = ?`, id).First(&order).Error + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return helper.AdminRefundStatusResp{}, fmt.Errorf("failed to get payment order: %w", err) + } + tradeNo := id + if err == nil { + tradeNo = order.TradeNO + } + query := g.ck.GetGlobalDB().Model(&types.PaymentRefund{}).Where(`trade_no = ?`, tradeNo) + var total int64 + if err := query.Count(&total).Error; err != nil { + return helper.AdminRefundStatusResp{}, fmt.Errorf("failed to count refunds: %w", err) + } + var latest types.PaymentRefund + latestErr := query.Order(`created_at DESC`).First(&latest).Error + response := helper.AdminRefundStatusResp{ + Refunded: total > 0, + RefundCount: int(total), + TradeNo: tradeNo, + } + if latestErr == nil { + response.LatestRefund = adminRefund(&latest) + } else if !errors.Is(latestErr, gorm.ErrRecordNotFound) { + return helper.AdminRefundStatusResp{}, fmt.Errorf( + "failed to get latest refund: %w", + latestErr, + ) + } + return response, nil +} + +func (g *Cockroach) GetAdminRechargeGiftPolicy() (helper.AdminRechargeGiftPolicy, error) { + var config types.Configs + err := g.ck.GetGlobalDB(). + Where(&types.Configs{Type: types.AccountConfigType}). + First(&config). + Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return helper.AdminRechargeGiftPolicy{}, nil + } + if err != nil { + return helper.AdminRechargeGiftPolicy{}, fmt.Errorf("failed to get account config: %w", err) + } + var accountConfig types.AccountConfig + if err := json.Unmarshal([]byte(config.Data), &accountConfig); err != nil { + return helper.AdminRechargeGiftPolicy{}, fmt.Errorf( + "failed to unmarshal account config: %w", + err, + ) + } + return helper.AdminRechargeGiftPolicy{ + DefaultDiscountSteps: intMapToStringMap(accountConfig.DefaultDiscountSteps), + FirstRechargeDiscountSteps: intMapToStringMap(accountConfig.FirstRechargeDiscountSteps), + }, nil +} + +func intMapToStringMap(input map[int64]int64) map[string]int64 { + output := make(map[string]int64, len(input)) + for key, value := range input { + output[strconv.FormatInt(key, 10)] = value + } + return output +} + +func (g *Cockroach) ListAdminRegions() ([]helper.AdminRegion, error) { + regions, err := g.GetRegions() + if err != nil { + return nil, err + } + result := make([]helper.AdminRegion, len(regions)) + for i := range regions { + result[i] = helper.AdminRegion{ + UID: regions[i].UID, + DisplayName: regions[i].DisplayName, + Location: regions[i].Location, + Domain: regions[i].Domain, + Description: regions[i].Description, + } + } + return result, nil +} diff --git a/service/account/dao/admin_read_test.go b/service/account/dao/admin_read_test.go new file mode 100644 index 000000000..05e224a83 --- /dev/null +++ b/service/account/dao/admin_read_test.go @@ -0,0 +1,108 @@ +package dao + +import ( + "strings" + "testing" + + "github.com/google/uuid" + "github.com/labring/sealos/controllers/pkg/types" +) + +func TestAdminPage(t *testing.T) { + got := adminPage(2, 10, 21) + want := struct { + pageIndex int + pageSize int + totalItems int64 + totalPages int + }{2, 10, 21, 3} + if got.PageIndex != want.pageIndex || + got.PageSize != want.pageSize || + got.TotalItems != want.totalItems || + got.TotalPages != want.totalPages { + t.Fatalf("adminPage() = %+v, want %+v", got, want) + } +} + +func TestNormalizeAdminTradeNo(t *testing.T) { + id := uuid.New() + var compact strings.Builder + for _, part := range []string{id.String()[:8], id.String()[9:13], id.String()[14:18], id.String()[19:23], id.String()[24:]} { + compact.WriteString(part) + } + if got := normalizeAdminTradeNo(compact.String()); got != id.String() { + t.Fatalf("normalizeAdminTradeNo(compact) = %q, want %q", got, id.String()) + } + if got := normalizeAdminTradeNo("not-a-uuid"); got != "" { + t.Fatalf("normalizeAdminTradeNo(invalid) = %q, want empty", got) + } +} + +func TestFormatAdminUser(t *testing.T) { + user := &types.User{ + UID: uuid.New(), + ID: "user-id", + Nickname: "User", + Status: types.UserStatusNormal, + } + account := &types.Account{Balance: 120, DeductionBalance: 30} + providers := []types.OauthProvider{ + {ProviderType: types.OauthProviderTypePassword, ProviderID: "username"}, + {ProviderType: types.OauthProviderTypeEmail, ProviderID: "user@example.com"}, + {ProviderType: types.OauthProviderTypeGithub, ProviderID: "github-id"}, + } + + got := formatAdminUser(user, providers, account) + if got.Username != "username" || got.Email != "user@example.com" || + got.SourceType != "ADMIN_CREATED" { + t.Fatalf("formatAdminUser() identity fields = %+v", got) + } + if len(got.SourceProviderTypes) != 1 || + got.SourceProviderTypes[0] != string(types.OauthProviderTypePassword) { + t.Fatalf("formatAdminUser() source providers = %v", got.SourceProviderTypes) + } + if got.Balance == nil || *got.Balance != 120 || got.DeductionBalance == nil || + *got.DeductionBalance != 30 || + got.AvailableBalance == nil || + *got.AvailableBalance != 90 { + t.Fatalf("formatAdminUser() balances = %+v", got) + } + if got.BillingStatus != "normal" { + t.Fatalf("formatAdminUser() billing status = %q, want normal", got.BillingStatus) + } +} + +func TestGroupAdminUserProviders(t *testing.T) { + userUID := uuid.New() + providers := []types.OauthProvider{ + { + UserUID: userUID, + ProviderType: types.OauthProviderTypeEmail, + ProviderID: "user@example.com", + }, + } + alertAccounts := []types.UserAlertNotificationAccount{ + { + UserUID: userUID, + ProviderType: types.OauthProviderTypeEmail, + ProviderID: "user@example.com", + }, + {UserUID: userUID, ProviderType: types.OauthProviderTypePhone, ProviderID: "+123456789"}, + {UserUID: userUID, ProviderType: types.OauthProviderTypeGithub, ProviderID: "github-id"}, + } + + got := groupAdminUserProviders(providers, alertAccounts)[userUID] + if len(got) != 2 { + t.Fatalf("groupAdminUserProviders() returned %d providers, want 2", len(got)) + } + if got[0].ProviderID != "user@example.com" || got[1].ProviderID != "+123456789" { + t.Fatalf("groupAdminUserProviders() = %+v", got) + } +} + +func TestIntMapToStringMap(t *testing.T) { + got := intMapToStringMap(map[int64]int64{100: 95, 1000: 850}) + if len(got) != 2 || got["100"] != 95 || got["1000"] != 850 { + t.Fatalf("intMapToStringMap() = %v", got) + } +} diff --git a/service/account/dao/init.go b/service/account/dao/init.go index d6c09f719..686a7f41b 100644 --- a/service/account/dao/init.go +++ b/service/account/dao/init.go @@ -61,6 +61,7 @@ var ( K8sManager ctrl.Manager Logger *logger.Logger HasGpuNode bool + AdminJwtMgr *utils.JWTManager // TODO: need init UserContactProvider usernotify.UserContactProvider @@ -155,6 +156,14 @@ func Init(ctx context.Context) error { return fmt.Errorf("empty jwt secret env: %s", helper.EnvJwtSecret) } JwtMgr = utils.NewJWTManager(os.Getenv(helper.EnvJwtSecret), time.Minute*30) + adminJwtSecret := os.Getenv(helper.EnvAdminJwtSecret) + if adminJwtSecret == "" { + return fmt.Errorf("empty admin jwt secret env: %s", helper.EnvAdminJwtSecret) + } + if adminJwtSecret == jwtSecret { + return fmt.Errorf("admin jwt secret must differ from %s", helper.EnvJwtSecret) + } + AdminJwtMgr = utils.NewJWTManager(adminJwtSecret, time.Minute*30) gatewayURL, clientID, privateKey, publicKey := os.Getenv( helper.EnvAlipayGatewayURL, diff --git a/service/account/dao/interface.go b/service/account/dao/interface.go index 63c9a6193..93653953c 100644 --- a/service/account/dao/interface.go +++ b/service/account/dao/interface.go @@ -144,6 +144,26 @@ type Interface interface { GetWorkspaceRemainingAIQuota(workspace string) (totalQuota, remainingQuota int64, err error) ChargeWorkspaceAIQuota(usage int64, workspace string) error + // Admin read-only account management methods. + ListAdminUsers(req helper.AdminUserListReq) (helper.AdminUserListResp, error) + GetAdminUser(id string) (*helper.AdminUserDetail, error) + ListAdminUserRechargeRecords( + id string, pageIndex, pageSize int, + ) (helper.AdminRechargeRecordsResp, error) + ListAdminUserBalanceAdjustRecords( + id string, pageIndex, pageSize int, + ) (helper.AdminBalanceAdjustRecordsResp, error) + ListAdminGiftCodes(req helper.AdminGiftCodeListReq) (helper.AdminGiftCodeListResp, error) + ListAdminGiftCodeUsage( + id string, + pageIndex, pageSize int, + ) (helper.AdminGiftCodeUsageResp, error) + ListAdminInvoices(req helper.AdminInvoiceListReq) (helper.AdminInvoiceListResp, error) + GetAdminInvoice(id string) (*helper.AdminInvoice, error) + GetAdminRefundStatus(id string) (helper.AdminRefundStatusResp, error) + GetAdminRechargeGiftPolicy() (helper.AdminRechargeGiftPolicy, error) + ListAdminRegions() ([]helper.AdminRegion, error) + ReloadConfig() error } diff --git a/service/account/deploy/README.md b/service/account/deploy/README.md index 89cba5aec..b0ed8c11e 100644 --- a/service/account/deploy/README.md +++ b/service/account/deploy/README.md @@ -85,6 +85,7 @@ sealos run labring/account-service:latest \ - `serviceAccountName`: 服务账户名称,默认 `account-controller-manager` - `envConfigMapName`: 环境 ConfigMap 名称,默认 `account-manager-env` +- `adminJwtSecretName`: admin API JWT Secret 名称,默认 `account-admin-jwt` - `paymentSecretName`: 支付密钥名称,默认 `payment-secret` - `regionInfoConfigMapName`: 区域信息 ConfigMap 名称,默认 `region-info` - `nameOverride`: 名称覆盖,默认 `""` @@ -148,7 +149,7 @@ sealos run labring/account-service:latest \ ### 依赖组件 -- **account-controller**: 提供 account-manager-env ConfigMap 和 service account +- **account-controller**: 提供 account-manager-env ConfigMap、account-admin-jwt Secret 和 service account - **payment-secret**: 支付配置密钥(可选) ## 部署流程 diff --git a/service/account/deploy/charts/account-service/templates/deployment.yaml b/service/account/deploy/charts/account-service/templates/deployment.yaml index 4eb1d67e4..97d3c9eb9 100644 --- a/service/account/deploy/charts/account-service/templates/deployment.yaml +++ b/service/account/deploy/charts/account-service/templates/deployment.yaml @@ -47,6 +47,8 @@ spec: envFrom: - configMapRef: name: {{ .Values.envConfigMapName }} + - secretRef: + name: {{ .Values.adminJwtSecretName }} - secretRef: name: {{ .Values.paymentSecretName }} optional: true diff --git a/service/account/deploy/charts/account-service/values.yaml b/service/account/deploy/charts/account-service/values.yaml index e5469a227..05e1b2e3f 100644 --- a/service/account/deploy/charts/account-service/values.yaml +++ b/service/account/deploy/charts/account-service/values.yaml @@ -73,6 +73,9 @@ serviceAccountName: account-controller-manager # ConfigMap for environment variables envConfigMapName: account-manager-env +# Secret containing the dedicated admin API signing key +adminJwtSecretName: account-admin-jwt + # Secret for payment configuration paymentSecretName: payment-secret @@ -98,4 +101,4 @@ ingress: # Cloud configuration cloudDomain: "cloud.sealos.io" -cloudPort: "" \ No newline at end of file +cloudPort: "" diff --git a/service/account/helper/admin_read.go b/service/account/helper/admin_read.go new file mode 100644 index 000000000..01e567e2f --- /dev/null +++ b/service/account/helper/admin_read.go @@ -0,0 +1,198 @@ +package helper + +import ( + "time" + + "github.com/google/uuid" +) + +type AdminPage struct { + PageIndex int `json:"pageIndex"` + PageSize int `json:"pageSize"` + TotalItems int64 `json:"totalItems"` + TotalPages int `json:"totalPages"` +} + +type AdminUserListReq struct { + PageIndex int + PageSize int + ID string + Username string + Phone string + WorkspaceID string + WorkspaceName string +} + +type AdminUser struct { + UID uuid.UUID `json:"uid"` + ID string `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Status string `json:"status"` + Phone string `json:"phone"` + Email string `json:"email"` + SourceType string `json:"sourceType"` + SourceProviderTypes []string `json:"sourceProviderTypes"` + Balance *int64 `json:"balance"` + DeductionBalance *int64 `json:"deductionBalance"` + AvailableBalance *int64 `json:"availableBalance"` + BillingStatus string `json:"billingStatus"` + Role string `json:"role,omitempty"` +} + +type AdminUserListResp struct { + AdminPage + List []AdminUser `json:"list"` +} + +type AdminWorkspace struct { + UID uuid.UUID `json:"uid"` + ID string `json:"id"` + DisplayName string `json:"displayName"` + Role string `json:"role"` + Status string `json:"status"` + IsPrivate bool `json:"isPrivate"` +} + +type AdminUserDetail struct { + AdminUser + RealName string `json:"realName"` + IDCard string `json:"idCard"` + UserType string `json:"userType"` + ProductSeries []string `json:"productSeries"` + RechargeAmount int64 `json:"rechargeAmount"` + Workspaces []AdminWorkspace `json:"workspaces"` +} + +type AdminRechargeRecord struct { + ID string `json:"id"` + TradeNo string `json:"tradeNo"` + CreatedAt time.Time `json:"createdAt"` + Method string `json:"method"` + Status string `json:"status"` + Amount int64 `json:"amount"` + Gift *int64 `json:"gift"` + Type string `json:"type"` + ChargeSource string `json:"chargeSource"` + Refunded bool `json:"refunded"` + RefundCount int `json:"refundCount"` + LatestRefund *AdminRefund `json:"latestRefund"` +} + +type AdminRefund struct { + ID string `json:"id"` + RefundNo string `json:"refundNo"` + RefundAmount int64 `json:"refundAmount"` + DeductAmount int64 `json:"deductAmount"` + CreatedAt time.Time `json:"createdAt"` + RefundReason string `json:"refundReason"` +} + +type AdminRechargeRecordsResp struct { + AdminPage + List []AdminRechargeRecord `json:"list"` +} + +type AdminBalanceAdjustRecord struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"createdAt"` + Type string `json:"type"` + Amount *int64 `json:"amount"` + BalanceBefore *int64 `json:"balanceBefore"` + BalanceAfter int64 `json:"balanceAfter"` + Message string `json:"message"` +} + +type AdminBalanceAdjustRecordsResp struct { + AdminPage + List []AdminBalanceAdjustRecord `json:"list"` +} + +type AdminGiftCode struct { + ID uuid.UUID `json:"id"` + Code string `json:"code"` + Used bool `json:"used"` + CreditAmount int64 `json:"creditAmount"` + UsedBy *uuid.UUID `json:"usedBy"` + UsedAt *time.Time `json:"usedAt"` + CreatedAt time.Time `json:"createdAt"` + ExpiredAt *time.Time `json:"expiredAt"` + Comment string `json:"comment"` + CreatedBy string `json:"createdBy"` + CreatedByID string `json:"createdById"` + RechargeType string `json:"rechargeType"` +} + +type AdminGiftCodeListReq struct { + PageIndex int + PageSize int + Status string + StartTime *time.Time + EndTime *time.Time + ID string + Code string + Comment string + RechargeType string +} + +type AdminGiftCodeListResp struct { + AdminPage + List []AdminGiftCode `json:"list"` +} + +type AdminGiftCodeUsage struct { + ID uuid.UUID `json:"id"` + Code string `json:"code"` + CreditAmount int64 `json:"creditAmount"` + UsedAt *time.Time `json:"usedAt"` + RechargeType string `json:"rechargeType"` + Comment string `json:"comment"` +} + +type AdminGiftCodeUsageResp struct { + AdminPage + List []AdminGiftCodeUsage `json:"list"` +} + +type AdminInvoice struct { + ID string `json:"id"` + UserID string `json:"userID"` + CreatedAt *time.Time `json:"createdAt"` + UpdatedAt *time.Time `json:"updatedAt"` + Detail string `json:"detail"` + Remark string `json:"remark"` + TotalAmount int64 `json:"totalAmount"` + Status string `json:"status"` +} + +type AdminInvoiceListReq struct { + PageIndex int + PageSize int + Status string + CompanyName string +} + +type AdminInvoiceListResp struct { + AdminPage + List []AdminInvoice `json:"list"` +} + +type AdminRefundStatusResp struct { + Refunded bool `json:"refunded"` + RefundCount int `json:"refundCount"` + TradeNo string `json:"tradeNo"` + LatestRefund *AdminRefund `json:"latestRefund"` +} + +type AdminRechargeGiftPolicy struct { + DefaultDiscountSteps map[string]int64 `json:"defaultDiscountSteps"` + FirstRechargeDiscountSteps map[string]int64 `json:"firstRechargeDiscountSteps"` +} + +type AdminRegion struct { + UID uuid.UUID `json:"uid"` + DisplayName string `json:"displayName"` + Location string `json:"location"` + Domain string `json:"domain"` + Description string `json:"description"` +} diff --git a/service/account/helper/common.go b/service/account/helper/common.go index 217629564..e4f1ee042 100644 --- a/service/account/helper/common.go +++ b/service/account/helper/common.go @@ -63,6 +63,19 @@ const ( AdminRefundForms = "/refund-forms" AdminCreateCorporate = "/corporate" + + // Admin read-only account management routes. + AdminUserList = "/users" + AdminUserDetailPath = "/user" + AdminUserRechargeRecordsPath = "/user/recharge-records" + AdminUserBalanceAdjustRecords = "/user/balance-adjust-records" + AdminGiftCodeList = "/gift-codes" + AdminGiftCodeUsagePath = "/gift-codes/usage" + AdminInvoiceList = "/invoices" + AdminInvoiceDetail = "/invoice" + AdminRefundStatus = "/refund-status" + AdminRechargeGiftPolicyPath = "/recharge-gift-policy" + AdminRegionList = "/regions" ) const ( @@ -113,6 +126,7 @@ const ( ENVLocalCockroach = "LOCAL_COCKROACH_URI" EnvLocalRegion = "LOCAL_REGION" EnvJwtSecret = "ACCOUNT_API_JWT_SECRET" + EnvAdminJwtSecret = "ACCOUNT_ADMIN_JWT_SECRET" EnvSubscriptionEnabled = "SUBSCRIPTION_ENABLED" EnvKycProcessEnabled = "KYC_PROCESS_ENABLED" diff --git a/service/account/router/router.go b/service/account/router/router.go index 3d1466806..ea45356a3 100644 --- a/service/account/router/router.go +++ b/service/account/router/router.go @@ -101,6 +101,17 @@ func RegisterPayRouter() { adminGroup := router.Group(helper.AdminGroup). GET(helper.AdminGetAccountWithWorkspace, api.AdminGetAccountWithWorkspaceID). GET(helper.AdminGetUserRealNameInfo, api.AdminGetUserRealNameInfo). + GET(helper.AdminUserList, api.AdminListUsers). + GET(helper.AdminUserDetailPath, api.AdminGetUser). + GET(helper.AdminUserRechargeRecordsPath, api.AdminListUserRechargeRecords). + GET(helper.AdminUserBalanceAdjustRecords, api.AdminListUserBalanceAdjustRecords). + GET(helper.AdminGiftCodeList, api.AdminListGiftCodes). + GET(helper.AdminGiftCodeUsagePath, api.AdminListGiftCodeUsage). + GET(helper.AdminInvoiceList, api.AdminListInvoices). + GET(helper.AdminInvoiceDetail, api.AdminGetInvoice). + GET(helper.AdminRefundStatus, api.AdminGetRefundStatus). + GET(helper.AdminRechargeGiftPolicyPath, api.AdminGetRechargeGiftPolicy). + GET(helper.AdminRegionList, api.AdminListRegions). GET(helper.AdminWorkspaceSubscriptionList, api.AdminWorkspaceSubscriptionListGET). GET(helper.AdminSubscriptionPlans, api.AdminSubscriptionPlansGET). POST(helper.AdminCreateCorporate, api.AdminCreateCorporate). diff --git a/service/go.work.sum b/service/go.work.sum index b2ae59767..5deba07f4 100644 --- a/service/go.work.sum +++ b/service/go.work.sum @@ -2184,6 +2184,7 @@ github.com/posener/complete v1.1.1 h1:ccV59UEOTzVDnDUEFdT95ZzHVZ+5+158q8+SJb2QV5 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b h1:0LFwY6Q3gMACTjAbMZBjXAqTOzOwFaj2Ld6cjeQ7Rig= github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= @@ -2300,12 +2301,14 @@ github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NF github.com/shiena/ansicolor v0.0.0-20151119151921-a422bbe96644 h1:X+yvsM2yrEktyI+b2qND5gpH8YhURn0k8OCaeRnkINo= github.com/shiena/ansicolor v0.0.0-20151119151921-a422bbe96644/go.mod h1:nkxAfR/5quYxwPZhyDxgasBMnRtBZd0FCEpawpjMUFg= github.com/shirou/gopsutil/v3 v3.23.1 h1:a9KKO+kGLKEvcPIs4W62v0nu3sciVDOOOPUD0Hz7z/4= +github.com/shirou/gopsutil/v3 v3.23.1/go.mod h1:NN6mnm5/0k8jw4cBfCnJtr5L7ErOTg18tMNpgFkn0hA= github.com/shirou/gopsutil/v3 v3.23.6 h1:5y46WPI9QBKBbK7EEccUPNXpJpNrvPuTD0O2zHEHT08= github.com/shirou/gopsutil/v3 v3.23.6/go.mod h1:j7QX50DrXYggrpN30W0Mo+I4/8U2UUIQrnrhqUeWrAU= github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636 h1:aSISeOcal5irEhJd1M+IrApc0PdcN7e7Aj4yuEnOrfQ= github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 h1:bUGsEnyNbVPw06Bs80sCeARAlK8lhwqGyi6UT8ymuGk= @@ -2501,6 +2504,7 @@ github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg= +github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= @@ -2729,6 +2733,7 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -2881,6 +2886,7 @@ golang.org/x/net v0.0.0-20210917221730-978cfadd31cf/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211123203042-d83791d6bcd9/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= @@ -3039,9 +3045,12 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -3064,6 +3073,7 @@ golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= @@ -3079,6 +3089,7 @@ golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJ golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo= golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= @@ -3104,6 +3115,7 @@ golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3 golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=