mirror of
https://github.com/labring/sealos.git
synced 2026-08-30 17:58:09 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user