feat(account): expose admin account read APIs (#7220)

* feat(account): expose admin account read APIs

* fix(account): satisfy golangci-lint

* fix(account): satisfy golines formatting

* fix(account): apply golangci-lint formatting

* fix(account): address review feedback

* feat(account): isolate admin jwt authentication

* test(account): cover property reload admin auth

* fix(account): satisfy context-aware request lint

* fix(account): address admin auth review feedback
This commit is contained in:
Yun Pan
2026-08-21 15:05:38 +08:00
committed by GitHub
parent b53b60d7bb
commit beea1e56cb
29 changed files with 2188 additions and 19 deletions
@@ -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)
+1 -1
View File
@@ -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 {
@@ -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")
}
@@ -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)
}
})
}
}
@@ -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 {
@@ -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 {
+5 -3
View File
@@ -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 的连接配置正确
@@ -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
@@ -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
@@ -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 }}
@@ -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"
+3 -1
View File
@@ -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)
+67
View File
@@ -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
}
+111
View File
@@ -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")
}
})
}
}
+12 -2
View File
@@ -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")
+88
View File
@@ -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)
}
})
}
}
+380
View File
@@ -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)
}
+115
View File
@@ -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())
}
}
+889
View File
@@ -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
}
+108
View File
@@ -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)
}
}
+9
View File
@@ -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,
+20
View File
@@ -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
}
+2 -1
View File
@@ -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**: 支付配置密钥(可选)
## 部署流程
@@ -47,6 +47,8 @@ spec:
envFrom:
- configMapRef:
name: {{ .Values.envConfigMapName }}
- secretRef:
name: {{ .Values.adminJwtSecretName }}
- secretRef:
name: {{ .Values.paymentSecretName }}
optional: true
@@ -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: ""
cloudPort: ""
+198
View File
@@ -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"`
}
+14
View File
@@ -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"
+11
View File
@@ -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).
+12
View File
@@ -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=