diff --git a/.github/environments/production.yml b/.github/environments/production.yml new file mode 100644 index 0000000..8e06538 --- /dev/null +++ b/.github/environments/production.yml @@ -0,0 +1,27 @@ +# Production Environment Configuration for GitHub Actions +# This file defines production-specific deployment settings + +environment: + name: production + url: https://api.ppanel.example.com + protection_rules: + - type: wait_timer + minutes: 5 + - type: reviewers + reviewers: + - "@admin-team" + - "@devops-team" + variables: + ENVIRONMENT: production + LOG_LEVEL: info + DEPLOY_TIMEOUT: 300 + +# Environment-specific secrets required: +# PRODUCTION_HOST - Production server hostname/IP +# PRODUCTION_USER - SSH username for production server +# PRODUCTION_SSH_KEY - SSH private key for production server +# PRODUCTION_PORT - SSH port (default: 22) +# PRODUCTION_URL - Application URL for health checks +# DATABASE_PASSWORD - Production database password +# REDIS_PASSWORD - Production Redis password +# JWT_SECRET - JWT secret key for production \ No newline at end of file diff --git a/.github/environments/staging.yml b/.github/environments/staging.yml new file mode 100644 index 0000000..a62b4c4 --- /dev/null +++ b/.github/environments/staging.yml @@ -0,0 +1,23 @@ +# Staging Environment Configuration for GitHub Actions +# This file defines staging-specific deployment settings + +environment: + name: staging + url: https://staging-api.ppanel.example.com + protection_rules: + - type: wait_timer + minutes: 2 + variables: + ENVIRONMENT: staging + LOG_LEVEL: debug + DEPLOY_TIMEOUT: 180 + +# Environment-specific secrets required: +# STAGING_HOST - Staging server hostname/IP +# STAGING_USER - SSH username for staging server +# STAGING_SSH_KEY - SSH private key for staging server +# STAGING_PORT - SSH port (default: 22) +# STAGING_URL - Application URL for health checks +# DATABASE_PASSWORD - Staging database password +# REDIS_PASSWORD - Staging Redis password +# JWT_SECRET - JWT secret key for staging \ No newline at end of file diff --git a/.github/workflows/deploy-linux.yml b/.github/workflows/deploy-linux.yml new file mode 100644 index 0000000..f3c5e53 --- /dev/null +++ b/.github/workflows/deploy-linux.yml @@ -0,0 +1,65 @@ +name: Build Linux Binary + +on: + push: + branches: [ main, master ] + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Version to build (leave empty for auto)' + required: false + type: string + +permissions: + contents: write + +jobs: + build: + name: Build Linux Binary + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.23.3' + cache: true + + - name: Build + env: + CGO_ENABLED: 0 + GOOS: linux + GOARCH: amd64 + run: | + VERSION=${{ github.event.inputs.version }} + if [ -z "$VERSION" ]; then + VERSION=$(git describe --tags --always --dirty) + fi + + echo "Building ppanel-server $VERSION" + go build -ldflags "-s -w -X main.Version=$VERSION" -o ppanel-server ./ppanel.go + tar -czf ppanel-server-${VERSION}-linux-amd64.tar.gz ppanel-server + sha256sum ppanel-server ppanel-server-${VERSION}-linux-amd64.tar.gz > checksum.txt + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ppanel-server-linux-amd64 + path: | + ppanel-server + ppanel-server-*-linux-amd64.tar.gz + checksum.txt + + - name: Create Release + if: startsWith(github.ref, 'refs/tags/') + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION=${GITHUB_REF#refs/tags/} + gh release create $VERSION --title "PPanel Server $VERSION" || true + gh release upload $VERSION ppanel-server-${VERSION}-linux-amd64.tar.gz checksum.txt diff --git a/.github/workflows/develop.yaml b/.github/workflows/develop.yaml deleted file mode 100644 index 7a7b958..0000000 --- a/.github/workflows/develop.yaml +++ /dev/null @@ -1,51 +0,0 @@ -name: Deploy - -on: - push: - branches: ["develop"] - pull_request: - branches: ["develop"] - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Log in to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Get short Git commit ID - id: vars - run: echo "COMMIT_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV - - - - name: Build Docker image - run: docker build --build-arg VERSION=${{ env.COMMIT_ID }} -t ${{ secrets.DOCKER_USERNAME }}/ppanel-server-dev:${{ env.COMMIT_ID }} . - - - name: Push Docker image - run: docker push ${{ secrets.DOCKER_USERNAME }}/ppanel-server-dev:${{ env.COMMIT_ID }} - -# - name: Deploy to server -# uses: appleboy/ssh-action@v0.1.6 -# with: -# host: ${{ secrets.SSH_HOST }} -# username: ${{ secrets.SSH_USER }} -# key: ${{ secrets.SSH_PRIVATE_KEY }} -# script: | -# if [ $(docker ps -a -q -f name=ppanel-server-dev) ]; then -# echo "Stopping and removing existing ppanel-server container..." -# docker stop ppanel-server-dev -# docker rm ppanel-server-dev -# else -# echo "No existing ppanel-server-dev container running." -# fi -# -# docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} -# docker run -d --restart=always --log-driver=journald --name ppanel-server-dev -p 8080:8080 -v /www/wwwroot/api/etc:/app/etc -v /www/wwwroot/api/logs:/app/logs --restart=always -d ${{ secrets.DOCKER_USERNAME }}/ppanel-server-dev:${{ env.COMMIT_ID }} -# \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 0480620..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,131 +0,0 @@ -name: Release -on: - push: - tags: - - 'v*' - -jobs: - build-docker: - runs-on: ubuntu-latest - env: - IMAGE_NAME: ppanel-server - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Extract version from git tag - id: version - run: echo "VERSION=$(git describe --tags --abbrev=0 | sed 's/^v//')" >> $GITHUB_ENV - - - name: Get short SHA - id: sha - run: echo "GIT_SHA=${GITHUB_SHA::8}" >> $GITHUB_ENV - - - name: Set BUILD_TIME env - run: echo BUILD_TIME=$(date --iso-8601=seconds) >> ${GITHUB_ENV} - - - - name: Build and push Docker image for main release - if: "!contains(github.ref_name, 'beta')" - uses: docker/build-push-action@v6 - with: - context: . - file: Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - build-args: | - VERSION=${{ env.VERSION }} - tags: | - ${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:latest - ${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}-${{ env.GIT_SHA }} - - - name: Build and push Docker image for beta release - if: contains(github.ref_name, 'beta') - uses: docker/build-push-action@v6 - with: - context: . - file: Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - build-args: | - VERSION=${{ env.VERSION }} - tags: | - ${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:beta - ${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}-${{ env.GIT_SHA }} - - release-notes: - runs-on: ubuntu-latest - needs: build-docker - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.21' - - - name: Install GoReleaser - run: | - go install github.com/goreleaser/goreleaser/v2@latest - - - name: Run GoReleaser - env: - GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} - run: | - goreleaser check - goreleaser release --clean - - releases-matrix: - name: Release ppanel-server binary - runs-on: ubuntu-latest - needs: release-notes # wait for release-notes job to finish - strategy: - matrix: - # build and publish in parallel: linux/386, linux/amd64, linux/arm64, - # windows/386, windows/amd64, windows/arm64, darwin/amd64, darwin/arm64 - goos: [ linux, windows, darwin ] - goarch: [ '386', amd64, arm64 ] - exclude: - - goarch: '386' - goos: darwin - - - steps: - - uses: actions/checkout@v2 - - name: Extract version from git tag - id: version - run: echo "VERSION=$(git describe --tags --abbrev=0 | sed 's/^v//')" >> $GITHUB_ENV - - - name: Set BUILD_TIME env - run: echo BUILD_TIME=$(date --iso-8601=seconds) >> ${GITHUB_ENV} - - - uses: actions/checkout@v4 - - uses: wangyoucao577/go-release-action@v1 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - goos: ${{ matrix.goos }} - goarch: ${{ matrix.goarch }} - asset_name: "ppanel-server-${{ matrix.goos }}-${{ matrix.goarch }}" - goversion: "https://dl.google.com/go/go1.23.3.linux-amd64.tar.gz" - project_path: "." - binary_name: "ppanel-server" - extra_files: LICENSE etc - ldflags: -X "github.com/perfect-panel/server/pkg/constant.Version=${{env.VERSION}}" -X "github.com/perfect-panel/server/pkg/constant.BuildTime=${{env.BUILD_TIME}}" diff --git a/.github/workflows/swagger.yaml b/.github/workflows/swagger.yaml deleted file mode 100644 index 177c2b0..0000000 --- a/.github/workflows/swagger.yaml +++ /dev/null @@ -1,81 +0,0 @@ -name: Go CI/CD with goctl and Swagger - -on: - # release: - # types: [published] - push: - branches: - - develop - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Install goctl - run: | - curl -L https://github.com/zeromicro/go-zero/releases/download/tools%2Fgoctl%2Fv1.7.2/goctl-v1.7.2-linux-amd64.tar.gz -o goctl-v1.7.2-linux-amd64.tar.gz - tar -xvzf goctl-v1.7.2-linux-amd64.tar.gz - chmod +x goctl - sudo mv goctl /usr/local/bin/goctl - goctl --version - - - name: Install goctl-swagger - run: | - curl -L https://github.com/tensionc/goctl-swagger/releases/download/v1.0.1/goctl-swagger-v1.0.1-linux-amd64.tar.gz -o goctl-swagger.tar.gz - tar -xvzf goctl-swagger.tar.gz - chmod +x goctl-swagger - sudo mv goctl-swagger /usr/local/bin/ - - - name: Generate Swagger file - run: | - mkdir -p swagger - goctl api plugin -plugin goctl-swagger='swagger -filename common.json -pack Response -response "[{\"name\":\"code\",\"type\":\"integer\",\"description\":\"状态码\"},{\"name\":\"msg\",\"type\":\"string\",\"description\":\"消息\"},{\"name\":\"data\",\"type\":\"object\",\"description\":\"数据\",\"is_data\":true}]";' -api ./apis/swagger_common.api -dir ./swagger - goctl api plugin -plugin goctl-swagger='swagger -filename user.json -pack Response -response "[{\"name\":\"code\",\"type\":\"integer\",\"description\":\"状态码\"},{\"name\":\"msg\",\"type\":\"string\",\"description\":\"消息\"},{\"name\":\"data\",\"type\":\"object\",\"description\":\"数据\",\"is_data\":true}]";' -api ./apis/swagger_user.api -dir ./swagger - goctl api plugin -plugin goctl-swagger='swagger -filename admin.json -pack Response -response "[{\"name\":\"code\",\"type\":\"integer\",\"description\":\"状态码\"},{\"name\":\"msg\",\"type\":\"string\",\"description\":\"消息\"},{\"name\":\"data\",\"type\":\"object\",\"description\":\"数据\",\"is_data\":true}]";' -api ./apis/swagger_admin.api -dir ./swagger - goctl api plugin -plugin goctl-swagger='swagger -filename ppanel.json -pack Response -response "[{\"name\":\"code\",\"type\":\"integer\",\"description\":\"状态码\"},{\"name\":\"msg\",\"type\":\"string\",\"description\":\"消息\"},{\"name\":\"data\",\"type\":\"object\",\"description\":\"数据\",\"is_data\":true}]";' -api ppanel.api -dir ./swagger - goctl api plugin -plugin goctl-swagger='swagger -filename node.json -pack Response -response "[{\"name\":\"code\",\"type\":\"integer\",\"description\":\"状态码\"},{\"name\":\"msg\",\"type\":\"string\",\"description\":\"消息\"},{\"name\":\"data\",\"type\":\"object\",\"description\":\"数据\",\"is_data\":true}]";' -api ./apis/swagger_node.api -dir ./swagger - - - - name: Verify Swagger file - run: | - test -f ./swagger/common.json - test -f ./swagger/user.json - test -f ./swagger/admin.json - - - name: Checkout target repository - uses: actions/checkout@v4 - with: - repository: perfect-panel/ppanel-docs - token: ${{ secrets.GH_TOKEN }} - path: ppanel-docs - persist-credentials: true - - - name: Verify or create public/swagger directory - run: | - mkdir -p ./ppanel-docs/public/swagger - - - name: Copy Swagger files - run: | - cp -rf swagger/* ppanel-docs/public/swagger - cd ppanel-docs - - - name: Check for file changes - run: | - cd ppanel-docs - git add . - git status - if [ "$(git status --porcelain)" ]; then - echo "Changes detected in the doc repository." - git config user.name "GitHub Actions" - git config user.email "actions@ppanel.dev" - git commit -m "Update Swagger files" - git push - else - echo "No changes detected." - exit 0 - fi - diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..6712871 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,130 @@ +version: 2 + +before: + hooks: + - go mod tidy + - go generate ./... + +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - "386" + - amd64 + - arm64 + ignore: + - goos: darwin + goarch: "386" + binary: ppanel-server + ldflags: + - -s -w + - -X "github.com/perfect-panel/server/pkg/constant.Version={{.Version}}" + - -X "github.com/perfect-panel/server/pkg/constant.BuildTime={{.Date}}" + - -X "github.com/perfect-panel/server/pkg/constant.GitCommit={{.Commit}}" + main: ./ppanel.go + +archives: + - format: tar.gz + name_template: >- + {{ .ProjectName }}- + {{- .Version }}- + {{- title .Os }}- + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + files: + - LICENSE + - etc/* + format_overrides: + - goos: windows + format: zip + +checksum: + name_template: "checksums.txt" + +snapshot: + name_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + use: github + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + - Merge pull request + groups: + - title: Features + regexp: "^.*feat[(\\w)]*:+.*$" + order: 0 + - title: 'Bug fixes' + regexp: "^.*fix[(\\w)]*:+.*$" + order: 1 + - title: Others + order: 999 + +dockers: + - image_templates: + - "{{ .Env.DOCKER_USERNAME }}/ppanel-server:{{ .Tag }}" + - "{{ .Env.DOCKER_USERNAME }}/ppanel-server:v{{ .Major }}" + - "{{ .Env.DOCKER_USERNAME }}/ppanel-server:v{{ .Major }}.{{ .Minor }}" + - "{{ .Env.DOCKER_USERNAME }}/ppanel-server:latest" + dockerfile: Dockerfile + build_flag_templates: + - "--pull" + - "--label=org.opencontainers.image.created={{.Date}}" + - "--label=org.opencontainers.image.title={{.ProjectName}}" + - "--label=org.opencontainers.image.revision={{.FullCommit}}" + - "--label=org.opencontainers.image.version={{.Version}}" + - "--platform=linux/amd64" + use: docker + extra_files: + - etc/ + + - image_templates: + - "{{ .Env.DOCKER_USERNAME }}/ppanel-server:{{ .Tag }}-arm64" + - "{{ .Env.DOCKER_USERNAME }}/ppanel-server:v{{ .Major }}.{{ .Minor }}-arm64" + dockerfile: Dockerfile + build_flag_templates: + - "--pull" + - "--label=org.opencontainers.image.created={{.Date}}" + - "--label=org.opencontainers.image.title={{.ProjectName}}" + - "--label=org.opencontainers.image.revision={{.FullCommit}}" + - "--label=org.opencontainers.image.version={{.Version}}" + - "--platform=linux/arm64" + use: docker + goarch: arm64 + extra_files: + - etc/ + +docker_signs: + - cmd: cosign + args: + - "sign" + - "${artifact}@${digest}" + env: + - COSIGN_EXPERIMENTAL=1 + +release: + github: + owner: perfect-panel + name: server + draft: false + prerelease: auto + name_template: "{{.ProjectName}} v{{.Version}}" + header: | + ## ppanel-server {{.Version}} + + Welcome to this new release! + footer: | + Docker images are available at: + - `{{ .Env.DOCKER_USERNAME }}/ppanel-server:{{ .Tag }}` + - `{{ .Env.DOCKER_USERNAME }}/ppanel-server:latest` + + For more information, visit our documentation. \ No newline at end of file diff --git a/.run/go build github.com_perfect-panel_server.run.xml b/.run/go build github.com_perfect-panel_server.run.xml new file mode 100644 index 0000000..608afe7 --- /dev/null +++ b/.run/go build github.com_perfect-panel_server.run.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/apis/admin/redemption.api b/apis/admin/redemption.api new file mode 100644 index 0000000..ca5632c --- /dev/null +++ b/apis/admin/redemption.api @@ -0,0 +1,95 @@ +syntax = "v1" + +info ( + title: "redemption API" + desc: "API for redemption code management" + author: "Tension" + email: "tension@ppanel.com" + version: "0.0.1" +) + +import "../types.api" + +type ( + CreateRedemptionCodeRequest { + TotalCount int64 `json:"total_count" validate:"required"` + SubscribePlan int64 `json:"subscribe_plan" validate:"required"` + UnitTime string `json:"unit_time" validate:"required,oneof=day month quarter half_year year"` + Quantity int64 `json:"quantity" validate:"required"` + BatchCount int64 `json:"batch_count" validate:"required,min=1"` + } + UpdateRedemptionCodeRequest { + Id int64 `json:"id" validate:"required"` + TotalCount int64 `json:"total_count,omitempty"` + SubscribePlan int64 `json:"subscribe_plan,omitempty"` + UnitTime string `json:"unit_time,omitempty" validate:"omitempty,oneof=day month quarter half_year year"` + Quantity int64 `json:"quantity,omitempty"` + Status int64 `json:"status,omitempty" validate:"omitempty,oneof=0 1"` + } + ToggleRedemptionCodeStatusRequest { + Id int64 `json:"id" validate:"required"` + Status int64 `json:"status" validate:"oneof=0 1"` + } + DeleteRedemptionCodeRequest { + Id int64 `json:"id" validate:"required"` + } + BatchDeleteRedemptionCodeRequest { + Ids []int64 `json:"ids" validate:"required"` + } + GetRedemptionCodeListRequest { + Page int64 `form:"page" validate:"required"` + Size int64 `form:"size" validate:"required"` + SubscribePlan int64 `form:"subscribe_plan,omitempty"` + UnitTime string `form:"unit_time,omitempty"` + Code string `form:"code,omitempty"` + } + GetRedemptionCodeListResponse { + Total int64 `json:"total"` + List []RedemptionCode `json:"list"` + } + GetRedemptionRecordListRequest { + Page int64 `form:"page" validate:"required"` + Size int64 `form:"size" validate:"required"` + UserId int64 `form:"user_id,omitempty"` + CodeId int64 `form:"code_id,omitempty"` + } + GetRedemptionRecordListResponse { + Total int64 `json:"total"` + List []RedemptionRecord `json:"list"` + } +) + +@server ( + prefix: v1/admin/redemption + group: admin/redemption + middleware: AuthMiddleware +) +service ppanel { + @doc "Create redemption code" + @handler CreateRedemptionCode + post /code (CreateRedemptionCodeRequest) + + @doc "Update redemption code" + @handler UpdateRedemptionCode + put /code (UpdateRedemptionCodeRequest) + + @doc "Toggle redemption code status" + @handler ToggleRedemptionCodeStatus + put /code/status (ToggleRedemptionCodeStatusRequest) + + @doc "Delete redemption code" + @handler DeleteRedemptionCode + delete /code (DeleteRedemptionCodeRequest) + + @doc "Batch delete redemption code" + @handler BatchDeleteRedemptionCode + delete /code/batch (BatchDeleteRedemptionCodeRequest) + + @doc "Get redemption code list" + @handler GetRedemptionCodeList + get /code/list (GetRedemptionCodeListRequest) returns (GetRedemptionCodeListResponse) + + @doc "Get redemption record list" + @handler GetRedemptionRecordList + get /record/list (GetRedemptionRecordListRequest) returns (GetRedemptionRecordListResponse) +} diff --git a/apis/public/redemption.api b/apis/public/redemption.api new file mode 100644 index 0000000..8bbb3ae --- /dev/null +++ b/apis/public/redemption.api @@ -0,0 +1,32 @@ +syntax = "v1" + +info ( + title: "redemption API" + desc: "API for redemption" + author: "Tension" + email: "tension@ppanel.com" + version: "0.0.1" +) + +import "../types.api" + +type ( + RedeemCodeRequest { + Code string `json:"code" validate:"required"` + } + RedeemCodeResponse { + Message string `json:"message"` + } +) + +@server ( + prefix: v1/public/redemption + group: public/redemption + jwt: JwtAuth + middleware: AuthMiddleware,DeviceMiddleware +) +service ppanel { + @doc "Redeem code" + @handler RedeemCode + post / (RedeemCodeRequest) returns (RedeemCodeResponse) +} diff --git a/apis/public/subscribe.api b/apis/public/subscribe.api index 25234ad..4c0d2aa 100644 --- a/apis/public/subscribe.api +++ b/apis/public/subscribe.api @@ -14,40 +14,48 @@ type ( QuerySubscribeListRequest { Language string `form:"language"` } - QueryUserSubscribeNodeListResponse { - List []UserSubscribeInfo `json:"list"` - } - UserSubscribeInfo { - Id int64 `json:"id"` - UserId int64 `json:"user_id"` - OrderId int64 `json:"order_id"` - SubscribeId int64 `json:"subscribe_id"` - StartTime int64 `json:"start_time"` - ExpireTime int64 `json:"expire_time"` - FinishedAt int64 `json:"finished_at"` - ResetTime int64 `json:"reset_time"` - Traffic int64 `json:"traffic"` - Download int64 `json:"download"` - Upload int64 `json:"upload"` - Token string `json:"token"` - Status uint8 `json:"status"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` - IsTryOut bool `json:"is_try_out"` - Nodes []*UserSubscribeNodeInfo `json:"nodes"` - } - UserSubscribeNodeInfo { - Id int64 `json:"id"` - Name string `json:"name"` - Uuid string `json:"uuid"` - Protocol string `json:"protocol"` - Port uint16 `json:"port"` - Address string `json:"address"` - Tags []string `json:"tags"` - Country string `json:"country"` - City string `json:"city"` - CreatedAt int64 `json:"created_at"` - } + + QueryUserSubscribeNodeListResponse { + List []UserSubscribeInfo `json:"list"` + } + + UserSubscribeInfo { + Id int64 `json:"id"` + UserId int64 `json:"user_id"` + OrderId int64 `json:"order_id"` + SubscribeId int64 `json:"subscribe_id"` + StartTime int64 `json:"start_time"` + ExpireTime int64 `json:"expire_time"` + FinishedAt int64 `json:"finished_at"` + ResetTime int64 `json:"reset_time"` + Traffic int64 `json:"traffic"` + Download int64 `json:"download"` + Upload int64 `json:"upload"` + Token string `json:"token"` + Status uint8 `json:"status"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + IsTryOut bool `json:"is_try_out"` + Nodes []*UserSubscribeNodeInfo `json:"nodes"` + } + + UserSubscribeNodeInfo{ + Id int64 `json:"id"` + Name string `json:"name"` + Uuid string `json:"uuid"` + Protocol string `json:"protocol"` + Protocols string `json:"protocols"` + Port uint16 `json:"port"` + Address string `json:"address"` + Tags []string `json:"tags"` + Country string `json:"country"` + City string `json:"city"` + Longitude string `json:"longitude"` + Latitude string `json:"latitude"` + LatitudeCenter string `json:"latitude_center"` + LongitudeCenter string `json:"longitude_center"` + CreatedAt int64 `json:"created_at"` + } ) @server ( @@ -60,8 +68,8 @@ service ppanel { @handler QuerySubscribeList get /list (QuerySubscribeListRequest) returns (QuerySubscribeListResponse) - @doc "Get user subscribe node info" - @handler QueryUserSubscribeNodeList - get /node/list returns (QueryUserSubscribeNodeListResponse) + @doc "Get user subscribe node info" + @handler QueryUserSubscribeNodeList + get /node/list returns (QueryUserSubscribeNodeListResponse) } diff --git a/apis/public/user.api b/apis/public/user.api index a02b758..c337603 100644 --- a/apis/public/user.api +++ b/apis/public/user.api @@ -66,6 +66,7 @@ type ( UnbindOAuthRequest { Method string `json:"method"` } + GetLoginLogRequest { Page int `form:"page"` Size int `form:"size"` @@ -94,17 +95,21 @@ type ( Email string `json:"email" validate:"required"` Code string `json:"code" validate:"required"` } - GetDeviceListResponse { - List []UserDevice `json:"list"` - Total int64 `json:"total"` - } - UnbindDeviceRequest { - Id int64 `json:"id" validate:"required"` - } + + GetDeviceListResponse { + List []UserDevice `json:"list"` + Total int64 `json:"total"` + } + + UnbindDeviceRequest { + Id int64 `json:"id" validate:"required"` + } + UpdateUserSubscribeNoteRequest { UserSubscribeId int64 `json:"user_subscribe_id" validate:"required"` Note string `json:"note" validate:"max=500"` } + UpdateUserRulesRequest { Rules []string `json:"rules" validate:"required"` } @@ -130,6 +135,23 @@ type ( List []WithdrawalLog `json:"list"` Total int64 `json:"total"` } + + + GetDeviceOnlineStatsResponse { + WeeklyStats []WeeklyStat `json:"weekly_stats"` + ConnectionRecords ConnectionRecords `json:"connection_records"` + } + + WeeklyStat { + Day int `json:"day"` + DayName string `json:"day_name"` + Hours float64 `json:"hours"` + } + ConnectionRecords { + CurrentContinuousDays int64 `json:"current_continuous_days"` + HistoryContinuousDays int64 `json:"history_continuous_days"` + LongestSingleConnection int64 `json:"longest_single_connection"` + } ) @server ( @@ -226,9 +248,9 @@ service ppanel { @handler UpdateBindEmail put /bind_email (UpdateBindEmailRequest) - @doc "Get Device List" - @handler GetDeviceList - get /devices returns (GetDeviceListResponse) + @doc "Get Device List" + @handler GetDeviceList + get /devices returns (GetDeviceListResponse) @doc "Unbind Device" @handler UnbindDevice @@ -249,5 +271,14 @@ service ppanel { @doc "Query Withdrawal Log" @handler QueryWithdrawalLog get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse) + + @doc "Device Online Statistics" + @handler DeviceOnlineStatistics + get /device_online_statistics returns (GetDeviceOnlineStatsResponse) + + @doc "Delete Current User Account" + @handler DeleteCurrentUserAccount + delete /current_user_account + } diff --git a/apis/types.api b/apis/types.api index 5f174be..780ced8 100644 --- a/apis/types.api +++ b/apis/types.api @@ -32,6 +32,7 @@ type ( CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` DeletedAt int64 `json:"deleted_at,omitempty"` + IsDel bool `json:"is_del,omitempty"` } Follow { Id int64 `json:"id"` @@ -150,6 +151,7 @@ type ( EnableIpRegisterLimit bool `json:"enable_ip_register_limit"` IpRegisterLimit int64 `json:"ip_register_limit"` IpRegisterLimitDuration int64 `json:"ip_register_limit_duration"` + DeviceLimit int64 `json:"device_limit"` } VerifyConfig { TurnstileSiteKey string `json:"turnstile_site_key"` @@ -204,7 +206,7 @@ type ( CurrencySymbol string `json:"currency_symbol"` } SubscribeDiscount { - Quantity int64 `json:"quantity"` + Quantity int64 `json:"quantity"` Discount float64 `json:"discount"` } Subscribe { @@ -446,6 +448,28 @@ type ( CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } + RedemptionCode { + Id int64 `json:"id"` + Code string `json:"code"` + TotalCount int64 `json:"total_count"` + UsedCount int64 `json:"used_count"` + SubscribePlan int64 `json:"subscribe_plan"` + UnitTime string `json:"unit_time"` + Quantity int64 `json:"quantity"` + Status int64 `json:"status"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } + RedemptionRecord { + Id int64 `json:"id"` + RedemptionCodeId int64 `json:"redemption_code_id"` + UserId int64 `json:"user_id"` + SubscribeId int64 `json:"subscribe_id"` + UnitTime string `json:"unit_time"` + Quantity int64 `json:"quantity"` + RedeemedAt int64 `json:"redeemed_at"` + CreatedAt int64 `json:"created_at"` + } Announcement { Id int64 `json:"id"` Title string `json:"title"` @@ -656,7 +680,7 @@ type ( // public announcement QueryAnnouncementRequest { Page int `form:"page"` - Size int `form:"size"` + Size int `form:"size,default=15"` Pinned *bool `form:"pinned"` Popup *bool `form:"popup"` } @@ -673,6 +697,7 @@ type ( List []SubscribeGroup `json:"list"` Total int64 `json:"total"` } + GetUserSubscribeTrafficLogsRequest { Page int `form:"page"` Size int `form:"size"` diff --git a/cache/GeoLite2-City.mmdb b/cache/GeoLite2-City.mmdb new file mode 100644 index 0000000..5878ff4 Binary files /dev/null and b/cache/GeoLite2-City.mmdb differ diff --git a/cmd/update.go b/cmd/update.go new file mode 100644 index 0000000..6a33dcb --- /dev/null +++ b/cmd/update.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "fmt" + + "github.com/perfect-panel/server/pkg/updater" + "github.com/spf13/cobra" +) + +var ( + checkOnly bool +) + +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Check for updates and update PPanel to the latest version", + Long: `Check for available updates from GitHub releases and automatically +update the PPanel binary to the latest version. + +Examples: + # Check for updates only + ppanel-server update --check + + # Update to the latest version + ppanel-server update`, + Run: func(cmd *cobra.Command, args []string) { + u := updater.NewUpdater() + + if checkOnly { + checkForUpdates(u) + return + } + + performUpdate(u) + }, +} + +func init() { + updateCmd.Flags().BoolVarP(&checkOnly, "check", "c", false, "Check for updates without applying them") +} + +func checkForUpdates(u *updater.Updater) { + fmt.Println("Checking for updates...") + + release, hasUpdate, err := u.CheckForUpdates() + if err != nil { + fmt.Printf("Error checking for updates: %v\n", err) + return + } + + if !hasUpdate { + fmt.Println("You are already running the latest version!") + return + } + + fmt.Printf("\nNew version available!\n") + fmt.Printf("Current version: %s\n", u.CurrentVersion) + fmt.Printf("Latest version: %s\n", release.TagName) + fmt.Printf("\nRelease notes:\n%s\n", release.Body) + fmt.Printf("\nTo update, run: ppanel-server update\n") +} + +func performUpdate(u *updater.Updater) { + fmt.Println("Starting update process...") + + if err := u.Update(); err != nil { + fmt.Printf("Update failed: %v\n", err) + return + } + + fmt.Println("\nUpdate completed successfully!") + fmt.Println("Please restart the application to use the new version.") +} diff --git a/go.mod b/go.mod index 9d0b191..aa17478 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/jinzhu/copier v0.4.0 github.com/klauspost/compress v1.17.7 github.com/nyaruka/phonenumbers v1.5.0 - github.com/pkg/errors v0.9.1 + github.com/pkg/errors v0.9.1 github.com/redis/go-redis/v9 v9.7.2 github.com/smartwalle/alipay/v3 v3.2.23 github.com/spf13/cast v1.7.0 // indirect diff --git a/initialize/migrate/database/02118_traffic_log_idx.up.sql b/initialize/migrate/database/02118_traffic_log_idx.up.sql index cdd308f..7928a61 100644 --- a/initialize/migrate/database/02118_traffic_log_idx.up.sql +++ b/initialize/migrate/database/02118_traffic_log_idx.up.sql @@ -1 +1,2 @@ -ALTER TABLE traffic_log ADD INDEX idx_timestamp (timestamp); +ALTER TABLE traffic_log ADD INDEX IF NOT EXISTS idx_timestamp (timestamp); + diff --git a/initialize/migrate/database/02119_node.down.sql b/initialize/migrate/database/02119_node.down.sql new file mode 100644 index 0000000..e69de29 diff --git a/initialize/migrate/database/02119_node.up.sql b/initialize/migrate/database/02119_node.up.sql new file mode 100644 index 0000000..447ce97 --- /dev/null +++ b/initialize/migrate/database/02119_node.up.sql @@ -0,0 +1,78 @@ +-- Only add the columns to `servers` when they do not already exist + +-- Add longitude +SET @col_exists := ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'servers' + AND COLUMN_NAME = 'longitude' +); + +SET @query := IF( + @col_exists = 0, + 'ALTER TABLE `servers` ADD COLUMN `longitude` VARCHAR(255) DEFAULT '''' COMMENT ''longitude'';', + 'SELECT "Column `longitude` already exists"' +); + +PREPARE stmt FROM @query; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Add latitude +SET @col_exists := ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'servers' + AND COLUMN_NAME = 'latitude' +); + +SET @query := IF( + @col_exists = 0, + 'ALTER TABLE `servers` ADD COLUMN `latitude` VARCHAR(255) DEFAULT '''' COMMENT ''latitude'';', + 'SELECT "Column `latitude` already exists"' +); + +PREPARE stmt FROM @query; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Add longitude_center +SET @col_exists := ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'servers' + AND COLUMN_NAME = 'longitude_center' +); + +SET @query := IF( + @col_exists = 0, + 'ALTER TABLE `servers` ADD COLUMN `longitude_center` VARCHAR(255) DEFAULT '''' COMMENT ''longitude center'';', + 'SELECT "Column `longitude_center` already exists"' +); + +PREPARE stmt FROM @query; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Add latitude_center +SET @col_exists := ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'servers' + AND COLUMN_NAME = 'latitude_center' +); + +SET @query := IF( + @col_exists = 0, + 'ALTER TABLE `servers` ADD COLUMN `latitude_center` VARCHAR(255) DEFAULT '''' COMMENT ''latitude center'';', + 'SELECT "Column `latitude_center` already exists"' +); + +PREPARE stmt FROM @query; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + diff --git a/initialize/migrate/database/02119_user_subscribe_note.down.sql b/initialize/migrate/database/02120_user_subscribe_note.down.sql similarity index 100% rename from initialize/migrate/database/02119_user_subscribe_note.down.sql rename to initialize/migrate/database/02120_user_subscribe_note.down.sql diff --git a/initialize/migrate/database/02119_user_subscribe_note.up.sql b/initialize/migrate/database/02120_user_subscribe_note.up.sql similarity index 100% rename from initialize/migrate/database/02119_user_subscribe_note.up.sql rename to initialize/migrate/database/02120_user_subscribe_note.up.sql diff --git a/initialize/migrate/database/02120_user_rules.down.sql b/initialize/migrate/database/02121_user_rules.down.sql similarity index 100% rename from initialize/migrate/database/02120_user_rules.down.sql rename to initialize/migrate/database/02121_user_rules.down.sql diff --git a/initialize/migrate/database/02120_user_rules.up.sql b/initialize/migrate/database/02121_user_rules.up.sql similarity index 100% rename from initialize/migrate/database/02120_user_rules.up.sql rename to initialize/migrate/database/02121_user_rules.up.sql diff --git a/initialize/migrate/database/02122_server.down.sql b/initialize/migrate/database/02122_server.down.sql deleted file mode 100644 index 7053859..0000000 --- a/initialize/migrate/database/02122_server.down.sql +++ /dev/null @@ -1,27 +0,0 @@ -CREATE TABLE IF NOT EXISTS `server` -( - `id` bigint NOT NULL AUTO_INCREMENT, - `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Node Name', - `tags` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Tags', - `country` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Country', - `city` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'City', - `latitude` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'latitude', - `longitude` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'longitude', - `server_addr` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Server Address', - `relay_mode` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'none' COMMENT 'Relay Mode', - `relay_node` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Relay Node', - `speed_limit` bigint NOT NULL DEFAULT '0' COMMENT 'Speed Limit', - `traffic_ratio` decimal(4, 2) NOT NULL DEFAULT '0.00' COMMENT 'Traffic Ratio', - `group_id` bigint DEFAULT NULL COMMENT 'Group ID', - `protocol` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Protocol', - `config` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Config', - `enable` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Enabled', - `sort` bigint NOT NULL DEFAULT '0' COMMENT 'Sort', - `last_reported_at` datetime(3) DEFAULT NULL COMMENT 'Last Reported Time', - `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', - `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', - PRIMARY KEY (`id`), - KEY `idx_group_id` (`group_id`) - ) ENGINE = InnoDB - DEFAULT CHARSET = utf8mb4 - COLLATE = utf8mb4_general_ci; diff --git a/initialize/migrate/database/02122_server.up.sql b/initialize/migrate/database/02122_server.up.sql deleted file mode 100644 index 2e506e1..0000000 --- a/initialize/migrate/database/02122_server.up.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS `server`; \ No newline at end of file diff --git a/initialize/migrate/database/02121_user_withdrawal.down.sql b/initialize/migrate/database/02122_user_withdrawal.down.sql similarity index 100% rename from initialize/migrate/database/02121_user_withdrawal.down.sql rename to initialize/migrate/database/02122_user_withdrawal.down.sql diff --git a/initialize/migrate/database/02121_user_withdrawal.up.sql b/initialize/migrate/database/02122_user_withdrawal.up.sql similarity index 100% rename from initialize/migrate/database/02121_user_withdrawal.up.sql rename to initialize/migrate/database/02122_user_withdrawal.up.sql diff --git a/initialize/migrate/database/02126_redemption.down.sql b/initialize/migrate/database/02126_redemption.down.sql new file mode 100644 index 0000000..3fdd96f --- /dev/null +++ b/initialize/migrate/database/02126_redemption.down.sql @@ -0,0 +1,5 @@ +-- Drop redemption_record table +DROP TABLE IF EXISTS `redemption_record`; + +-- Drop redemption_code table +DROP TABLE IF EXISTS `redemption_code`; diff --git a/initialize/migrate/database/02126_redemption.up.sql b/initialize/migrate/database/02126_redemption.up.sql new file mode 100644 index 0000000..e1e480d --- /dev/null +++ b/initialize/migrate/database/02126_redemption.up.sql @@ -0,0 +1,31 @@ +-- Create redemption_code table +CREATE TABLE IF NOT EXISTS `redemption_code` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT 'Primary Key', + `code` VARCHAR(255) NOT NULL COMMENT 'Redemption Code', + `total_count` BIGINT NOT NULL DEFAULT 0 COMMENT 'Total Redemption Count', + `used_count` BIGINT NOT NULL DEFAULT 0 COMMENT 'Used Redemption Count', + `subscribe_plan` BIGINT NOT NULL DEFAULT 0 COMMENT 'Subscribe Plan', + `unit_time` VARCHAR(50) NOT NULL DEFAULT 'month' COMMENT 'Unit Time: day, month, quarter, half_year, year', + `quantity` BIGINT NOT NULL DEFAULT 1 COMMENT 'Quantity', + `created_at` DATETIME NOT NULL COMMENT 'Creation Time', + `updated_at` DATETIME NOT NULL COMMENT 'Update Time', + `deleted_at` DATETIME DEFAULT NULL COMMENT 'Deletion Time', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_code` (`code`), + KEY `idx_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Redemption Code Table'; + +-- Create redemption_record table +CREATE TABLE IF NOT EXISTS `redemption_record` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT 'Primary Key', + `redemption_code_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'Redemption Code Id', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'User Id', + `subscribe_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'Subscribe Id', + `unit_time` VARCHAR(50) NOT NULL DEFAULT 'month' COMMENT 'Unit Time', + `quantity` BIGINT NOT NULL DEFAULT 1 COMMENT 'Quantity', + `redeemed_at` DATETIME NOT NULL COMMENT 'Redeemed Time', + `created_at` DATETIME NOT NULL COMMENT 'Creation Time', + PRIMARY KEY (`id`), + KEY `idx_redemption_code_id` (`redemption_code_id`), + KEY `idx_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Redemption Record Table'; diff --git a/initialize/migrate/database/02127_redemption_status.down.sql b/initialize/migrate/database/02127_redemption_status.down.sql new file mode 100644 index 0000000..ffa70f2 --- /dev/null +++ b/initialize/migrate/database/02127_redemption_status.down.sql @@ -0,0 +1,2 @@ +-- Remove status column from redemption_code table +ALTER TABLE `redemption_code` DROP COLUMN `status`; diff --git a/initialize/migrate/database/02127_redemption_status.up.sql b/initialize/migrate/database/02127_redemption_status.up.sql new file mode 100644 index 0000000..fc66cd4 --- /dev/null +++ b/initialize/migrate/database/02127_redemption_status.up.sql @@ -0,0 +1,2 @@ +-- Add status column to redemption_code table +ALTER TABLE `redemption_code` ADD COLUMN `status` TINYINT NOT NULL DEFAULT 1 COMMENT 'Status: 1=enabled, 0=disabled' AFTER `quantity`; diff --git a/initialize/migrate/database/02128_device_limit.down.sql b/initialize/migrate/database/02128_device_limit.down.sql new file mode 100644 index 0000000..af7fa4a --- /dev/null +++ b/initialize/migrate/database/02128_device_limit.down.sql @@ -0,0 +1,2 @@ +-- Remove device limit configuration from system table +DELETE FROM `system` WHERE `category` = 'register' AND `key` = 'DeviceLimit'; diff --git a/initialize/migrate/database/02128_device_limit.up.sql b/initialize/migrate/database/02128_device_limit.up.sql new file mode 100644 index 0000000..4c0a044 --- /dev/null +++ b/initialize/migrate/database/02128_device_limit.up.sql @@ -0,0 +1,3 @@ +-- Add device limit configuration to system table +INSERT IGNORE INTO `system` (`id`, `category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`) +VALUES (43, 'register', 'DeviceLimit', '5', 'int', 'Device binding limit', NOW(), NOW()); diff --git a/internal/config/cacheKey.go b/internal/config/cacheKey.go index 655ce55..3bce8bd 100644 --- a/internal/config/cacheKey.go +++ b/internal/config/cacheKey.go @@ -39,6 +39,9 @@ const VerifyCodeConfigKey = "system:verify_code_config" // SessionIdKey cache session key const SessionIdKey = "auth:session_id" +// DeviceCacheKeyKey cache session key +const DeviceCacheKeyKey = "auth:device_identifier" + // GlobalConfigKey Global Config Key const GlobalConfigKey = "system:global_config" @@ -59,3 +62,5 @@ const SendIntervalKeyPrefix = "send:interval:" // SendCountLimitKeyPrefix Send Count Limit Key Prefix eg. send:limit:register:email:xxx@ppanel.dev const SendCountLimitKeyPrefix = "send:limit:" + +const RegisterIpKeyPrefix = "register:ip:" diff --git a/internal/config/config.go b/internal/config/config.go index fc93da5..07e1e50 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -73,6 +73,7 @@ type RegisterConfig struct { IpRegisterLimit int64 `yaml:"IpRegisterLimit" default:"0"` IpRegisterLimitDuration int64 `yaml:"IpRegisterLimitDuration" default:"0"` EnableIpRegisterLimit bool `yaml:"EnableIpRegisterLimit" default:"false"` + DeviceLimit int64 `yaml:"DeviceLimit" default:"5"` } type EmailConfig struct { diff --git a/internal/handler/admin/redemption/batchDeleteRedemptionCodeHandler.go b/internal/handler/admin/redemption/batchDeleteRedemptionCodeHandler.go new file mode 100644 index 0000000..260fef8 --- /dev/null +++ b/internal/handler/admin/redemption/batchDeleteRedemptionCodeHandler.go @@ -0,0 +1,26 @@ +package redemption + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/redemption" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Batch delete redemption code +func BatchDeleteRedemptionCodeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.BatchDeleteRedemptionCodeRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := redemption.NewBatchDeleteRedemptionCodeLogic(c.Request.Context(), svcCtx) + err := l.BatchDeleteRedemptionCode(&req) + result.HttpResult(c, nil, err) + } +} diff --git a/internal/handler/admin/redemption/createRedemptionCodeHandler.go b/internal/handler/admin/redemption/createRedemptionCodeHandler.go new file mode 100644 index 0000000..787bc6b --- /dev/null +++ b/internal/handler/admin/redemption/createRedemptionCodeHandler.go @@ -0,0 +1,26 @@ +package redemption + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/redemption" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Create redemption code +func CreateRedemptionCodeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.CreateRedemptionCodeRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := redemption.NewCreateRedemptionCodeLogic(c.Request.Context(), svcCtx) + err := l.CreateRedemptionCode(&req) + result.HttpResult(c, nil, err) + } +} diff --git a/internal/handler/admin/redemption/deleteRedemptionCodeHandler.go b/internal/handler/admin/redemption/deleteRedemptionCodeHandler.go new file mode 100644 index 0000000..231c611 --- /dev/null +++ b/internal/handler/admin/redemption/deleteRedemptionCodeHandler.go @@ -0,0 +1,26 @@ +package redemption + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/redemption" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Delete redemption code +func DeleteRedemptionCodeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.DeleteRedemptionCodeRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := redemption.NewDeleteRedemptionCodeLogic(c.Request.Context(), svcCtx) + err := l.DeleteRedemptionCode(&req) + result.HttpResult(c, nil, err) + } +} diff --git a/internal/handler/admin/redemption/getRedemptionCodeListHandler.go b/internal/handler/admin/redemption/getRedemptionCodeListHandler.go new file mode 100644 index 0000000..d9f9f34 --- /dev/null +++ b/internal/handler/admin/redemption/getRedemptionCodeListHandler.go @@ -0,0 +1,26 @@ +package redemption + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/redemption" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Get redemption code list +func GetRedemptionCodeListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.GetRedemptionCodeListRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := redemption.NewGetRedemptionCodeListLogic(c.Request.Context(), svcCtx) + resp, err := l.GetRedemptionCodeList(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/admin/redemption/getRedemptionRecordListHandler.go b/internal/handler/admin/redemption/getRedemptionRecordListHandler.go new file mode 100644 index 0000000..77ad6cb --- /dev/null +++ b/internal/handler/admin/redemption/getRedemptionRecordListHandler.go @@ -0,0 +1,26 @@ +package redemption + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/redemption" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Get redemption record list +func GetRedemptionRecordListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.GetRedemptionRecordListRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := redemption.NewGetRedemptionRecordListLogic(c.Request.Context(), svcCtx) + resp, err := l.GetRedemptionRecordList(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/admin/redemption/toggleRedemptionCodeStatusHandler.go b/internal/handler/admin/redemption/toggleRedemptionCodeStatusHandler.go new file mode 100644 index 0000000..2ac2bfc --- /dev/null +++ b/internal/handler/admin/redemption/toggleRedemptionCodeStatusHandler.go @@ -0,0 +1,26 @@ +package redemption + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/redemption" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Toggle redemption code status +func ToggleRedemptionCodeStatusHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.ToggleRedemptionCodeStatusRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := redemption.NewToggleRedemptionCodeStatusLogic(c.Request.Context(), svcCtx) + err := l.ToggleRedemptionCodeStatus(&req) + result.HttpResult(c, nil, err) + } +} diff --git a/internal/handler/admin/redemption/updateRedemptionCodeHandler.go b/internal/handler/admin/redemption/updateRedemptionCodeHandler.go new file mode 100644 index 0000000..d5db12b --- /dev/null +++ b/internal/handler/admin/redemption/updateRedemptionCodeHandler.go @@ -0,0 +1,26 @@ +package redemption + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/redemption" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Update redemption code +func UpdateRedemptionCodeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.UpdateRedemptionCodeRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := redemption.NewUpdateRedemptionCodeLogic(c.Request.Context(), svcCtx) + err := l.UpdateRedemptionCode(&req) + result.HttpResult(c, nil, err) + } +} diff --git a/internal/handler/public/redemption/redeemCodeHandler.go b/internal/handler/public/redemption/redeemCodeHandler.go new file mode 100644 index 0000000..eb76e5b --- /dev/null +++ b/internal/handler/public/redemption/redeemCodeHandler.go @@ -0,0 +1,26 @@ +package redemption + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/public/redemption" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Redeem code +func RedeemCodeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.RedeemCodeRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := redemption.NewRedeemCodeLogic(c.Request.Context(), svcCtx) + resp, err := l.RedeemCode(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/public/user/deleteCurrentUserAccountHandler.go b/internal/handler/public/user/deleteCurrentUserAccountHandler.go new file mode 100644 index 0000000..9b80a36 --- /dev/null +++ b/internal/handler/public/user/deleteCurrentUserAccountHandler.go @@ -0,0 +1,18 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/public/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/result" +) + +// Delete Current User Account +func DeleteCurrentUserAccountHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + + l := user.NewDeleteCurrentUserAccountLogic(c.Request.Context(), svcCtx) + err := l.DeleteCurrentUserAccount() + result.HttpResult(c, nil, err) + } +} diff --git a/internal/handler/public/user/deviceOnlineStatisticsHandler.go b/internal/handler/public/user/deviceOnlineStatisticsHandler.go new file mode 100644 index 0000000..aa4afef --- /dev/null +++ b/internal/handler/public/user/deviceOnlineStatisticsHandler.go @@ -0,0 +1,18 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/public/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/result" +) + +// Device Online Statistics +func DeviceOnlineStatisticsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + + l := user.NewDeviceOnlineStatisticsLogic(c.Request.Context(), svcCtx) + resp, err := l.DeviceOnlineStatistics() + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/public/user/ws/deviceWsConnectHandler.go b/internal/handler/public/user/ws/deviceWsConnectHandler.go new file mode 100644 index 0000000..d07562c --- /dev/null +++ b/internal/handler/public/user/ws/deviceWsConnectHandler.go @@ -0,0 +1,29 @@ +package ws + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + wslogic "github.com/perfect-panel/server/internal/logic/public/user/ws" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/result" +) + +var upGrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + return true // 允许所有来源,生产环境中应该根据需求限制 + }, +} + +// Webosocket Device Connect +func DeviceWsConnectHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + + l := wslogic.NewDeviceWsConnectLogic(c.Request.Context(), svcCtx) + err := l.DeviceWsConnect(c) + result.HttpResult(c, nil, err) + } +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 6a942a7..47f16a5 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -16,6 +16,7 @@ import ( adminMarketing "github.com/perfect-panel/server/internal/handler/admin/marketing" adminOrder "github.com/perfect-panel/server/internal/handler/admin/order" adminPayment "github.com/perfect-panel/server/internal/handler/admin/payment" + adminRedemption "github.com/perfect-panel/server/internal/handler/admin/redemption" adminServer "github.com/perfect-panel/server/internal/handler/admin/server" adminSubscribe "github.com/perfect-panel/server/internal/handler/admin/subscribe" adminSystem "github.com/perfect-panel/server/internal/handler/admin/system" @@ -30,6 +31,7 @@ import ( publicOrder "github.com/perfect-panel/server/internal/handler/public/order" publicPayment "github.com/perfect-panel/server/internal/handler/public/payment" publicPortal "github.com/perfect-panel/server/internal/handler/public/portal" + publicRedemption "github.com/perfect-panel/server/internal/handler/public/redemption" publicSubscribe "github.com/perfect-panel/server/internal/handler/public/subscribe" publicTicket "github.com/perfect-panel/server/internal/handler/public/ticket" publicUser "github.com/perfect-panel/server/internal/handler/public/user" @@ -298,6 +300,32 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { adminPaymentGroupRouter.GET("/platform", adminPayment.GetPaymentPlatformHandler(serverCtx)) } + adminRedemptionGroupRouter := router.Group("/v1/admin/redemption") + adminRedemptionGroupRouter.Use(middleware.AuthMiddleware(serverCtx)) + + { + // Create redemption code + adminRedemptionGroupRouter.POST("/code", adminRedemption.CreateRedemptionCodeHandler(serverCtx)) + + // Update redemption code + adminRedemptionGroupRouter.PUT("/code", adminRedemption.UpdateRedemptionCodeHandler(serverCtx)) + + // Delete redemption code + adminRedemptionGroupRouter.DELETE("/code", adminRedemption.DeleteRedemptionCodeHandler(serverCtx)) + + // Batch delete redemption code + adminRedemptionGroupRouter.DELETE("/code/batch", adminRedemption.BatchDeleteRedemptionCodeHandler(serverCtx)) + + // Get redemption code list + adminRedemptionGroupRouter.GET("/code/list", adminRedemption.GetRedemptionCodeListHandler(serverCtx)) + + // Toggle redemption code status + adminRedemptionGroupRouter.PUT("/code/status", adminRedemption.ToggleRedemptionCodeStatusHandler(serverCtx)) + + // Get redemption record list + adminRedemptionGroupRouter.GET("/record/list", adminRedemption.GetRedemptionRecordListHandler(serverCtx)) + } + adminServerGroupRouter := router.Group("/v1/admin/server") adminServerGroupRouter.Use(middleware.AuthMiddleware(serverCtx)) @@ -748,6 +776,14 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { publicPortalGroupRouter.GET("/subscribe", publicPortal.GetSubscriptionHandler(serverCtx)) } + publicRedemptionGroupRouter := router.Group("/v1/public/redemption") + publicRedemptionGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx)) + + { + // Redeem code + publicRedemptionGroupRouter.POST("/", publicRedemption.RedeemCodeHandler(serverCtx)) + } + publicSubscribeGroupRouter := router.Group("/v1/public/subscribe") publicSubscribeGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx)) @@ -813,6 +849,12 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Commission Withdraw publicUserGroupRouter.POST("/commission_withdraw", publicUser.CommissionWithdrawHandler(serverCtx)) + // Delete Current User Account + publicUserGroupRouter.DELETE("/current_user_account", publicUser.DeleteCurrentUserAccountHandler(serverCtx)) + + // Device Online Statistics + publicUserGroupRouter.GET("/device_online_statistics", publicUser.DeviceOnlineStatisticsHandler(serverCtx)) + // Get Device List publicUserGroupRouter.GET("/devices", publicUser.GetDeviceListHandler(serverCtx)) diff --git a/internal/logic/admin/redemption/batchDeleteRedemptionCodeLogic.go b/internal/logic/admin/redemption/batchDeleteRedemptionCodeLogic.go new file mode 100644 index 0000000..ac4605a --- /dev/null +++ b/internal/logic/admin/redemption/batchDeleteRedemptionCodeLogic.go @@ -0,0 +1,36 @@ +package redemption + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type BatchDeleteRedemptionCodeLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Batch delete redemption code +func NewBatchDeleteRedemptionCodeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *BatchDeleteRedemptionCodeLogic { + return &BatchDeleteRedemptionCodeLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *BatchDeleteRedemptionCodeLogic) BatchDeleteRedemptionCode(req *types.BatchDeleteRedemptionCodeRequest) error { + err := l.svcCtx.RedemptionCodeModel.BatchDelete(l.ctx, req.Ids) + if err != nil { + l.Errorw("[BatchDeleteRedemptionCode] Database Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "batch delete redemption code error: %v", err.Error()) + } + + return nil +} diff --git a/internal/logic/admin/redemption/createRedemptionCodeLogic.go b/internal/logic/admin/redemption/createRedemptionCodeLogic.go new file mode 100644 index 0000000..4c381a0 --- /dev/null +++ b/internal/logic/admin/redemption/createRedemptionCodeLogic.go @@ -0,0 +1,120 @@ +package redemption + +import ( + "context" + "crypto/rand" + "math/big" + + "github.com/perfect-panel/server/internal/model/redemption" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +type CreateRedemptionCodeLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Create redemption code +func NewCreateRedemptionCodeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateRedemptionCodeLogic { + return &CreateRedemptionCodeLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +// generateUniqueCode generates a unique redemption code +func (l *CreateRedemptionCodeLogic) generateUniqueCode() (string, error) { + const charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // Removed confusing characters like I, O, 0, 1 + const codeLength = 16 + + maxRetries := 10 + for i := 0; i < maxRetries; i++ { + code := make([]byte, codeLength) + for j := 0; j < codeLength; j++ { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + return "", err + } + code[j] = charset[num.Int64()] + } + + codeStr := string(code) + + // Check if code already exists + _, err := l.svcCtx.RedemptionCodeModel.FindOneByCode(l.ctx, codeStr) + if errors.Is(err, gorm.ErrRecordNotFound) { + return codeStr, nil + } else if err != nil { + return "", err + } + // Code exists, try again + } + + return "", errors.New("failed to generate unique code after maximum retries") +} + +func (l *CreateRedemptionCodeLogic) CreateRedemptionCode(req *types.CreateRedemptionCodeRequest) error { + // Check if subscribe plan is valid + if req.SubscribePlan == 0 { + l.Errorw("[CreateRedemptionCode] Subscribe plan cannot be empty") + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "subscribe plan cannot be empty") + } + + // Verify subscribe plan exists + _, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, req.SubscribePlan) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + l.Errorw("[CreateRedemptionCode] Subscribe plan not found", logger.Field("subscribe_plan", req.SubscribePlan)) + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "subscribe plan not found") + } + l.Errorw("[CreateRedemptionCode] Database Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find subscribe plan error: %v", err.Error()) + } + + // Validate batch count + if req.BatchCount < 1 { + l.Errorw("[CreateRedemptionCode] Batch count must be at least 1") + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "batch count must be at least 1") + } + + // Generate redemption codes in batch + var createdCodes []string + for i := int64(0); i < req.BatchCount; i++ { + code, err := l.generateUniqueCode() + if err != nil { + l.Errorw("[CreateRedemptionCode] Failed to generate unique code", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "generate unique code error: %v", err.Error()) + } + + redemptionCode := &redemption.RedemptionCode{ + Code: code, + TotalCount: req.TotalCount, + UsedCount: 0, + SubscribePlan: req.SubscribePlan, + UnitTime: req.UnitTime, + Quantity: req.Quantity, + Status: 1, // Default to enabled + } + + err = l.svcCtx.RedemptionCodeModel.Insert(l.ctx, redemptionCode) + if err != nil { + l.Errorw("[CreateRedemptionCode] Database Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create redemption code error: %v", err.Error()) + } + + createdCodes = append(createdCodes, code) + } + + l.Infow("[CreateRedemptionCode] Successfully created redemption codes", + logger.Field("count", len(createdCodes)), + logger.Field("codes", createdCodes)) + + return nil +} diff --git a/internal/logic/admin/redemption/deleteRedemptionCodeLogic.go b/internal/logic/admin/redemption/deleteRedemptionCodeLogic.go new file mode 100644 index 0000000..4afbf72 --- /dev/null +++ b/internal/logic/admin/redemption/deleteRedemptionCodeLogic.go @@ -0,0 +1,36 @@ +package redemption + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type DeleteRedemptionCodeLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Delete redemption code +func NewDeleteRedemptionCodeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteRedemptionCodeLogic { + return &DeleteRedemptionCodeLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *DeleteRedemptionCodeLogic) DeleteRedemptionCode(req *types.DeleteRedemptionCodeRequest) error { + err := l.svcCtx.RedemptionCodeModel.Delete(l.ctx, req.Id) + if err != nil { + l.Errorw("[DeleteRedemptionCode] Database Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete redemption code error: %v", err.Error()) + } + + return nil +} diff --git a/internal/logic/admin/redemption/getRedemptionCodeListLogic.go b/internal/logic/admin/redemption/getRedemptionCodeListLogic.go new file mode 100644 index 0000000..88e5c80 --- /dev/null +++ b/internal/logic/admin/redemption/getRedemptionCodeListLogic.go @@ -0,0 +1,62 @@ +package redemption + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type GetRedemptionCodeListLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Get redemption code list +func NewGetRedemptionCodeListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetRedemptionCodeListLogic { + return &GetRedemptionCodeListLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetRedemptionCodeListLogic) GetRedemptionCodeList(req *types.GetRedemptionCodeListRequest) (resp *types.GetRedemptionCodeListResponse, err error) { + total, list, err := l.svcCtx.RedemptionCodeModel.QueryRedemptionCodeListByPage( + l.ctx, + int(req.Page), + int(req.Size), + req.SubscribePlan, + req.UnitTime, + req.Code, + ) + if err != nil { + l.Errorw("[GetRedemptionCodeList] Database Error", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "get redemption code list error: %v", err.Error()) + } + + var redemptionCodes []types.RedemptionCode + for _, item := range list { + redemptionCodes = append(redemptionCodes, types.RedemptionCode{ + Id: item.Id, + Code: item.Code, + TotalCount: item.TotalCount, + UsedCount: item.UsedCount, + SubscribePlan: item.SubscribePlan, + UnitTime: item.UnitTime, + Quantity: item.Quantity, + Status: item.Status, + CreatedAt: item.CreatedAt.Unix(), + UpdatedAt: item.UpdatedAt.Unix(), + }) + } + + return &types.GetRedemptionCodeListResponse{ + Total: total, + List: redemptionCodes, + }, nil +} diff --git a/internal/logic/admin/redemption/getRedemptionRecordListLogic.go b/internal/logic/admin/redemption/getRedemptionRecordListLogic.go new file mode 100644 index 0000000..5631fab --- /dev/null +++ b/internal/logic/admin/redemption/getRedemptionRecordListLogic.go @@ -0,0 +1,59 @@ +package redemption + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type GetRedemptionRecordListLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Get redemption record list +func NewGetRedemptionRecordListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetRedemptionRecordListLogic { + return &GetRedemptionRecordListLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetRedemptionRecordListLogic) GetRedemptionRecordList(req *types.GetRedemptionRecordListRequest) (resp *types.GetRedemptionRecordListResponse, err error) { + total, list, err := l.svcCtx.RedemptionRecordModel.QueryRedemptionRecordListByPage( + l.ctx, + int(req.Page), + int(req.Size), + req.UserId, + req.CodeId, + ) + if err != nil { + l.Errorw("[GetRedemptionRecordList] Database Error", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "get redemption record list error: %v", err.Error()) + } + + var redemptionRecords []types.RedemptionRecord + for _, item := range list { + redemptionRecords = append(redemptionRecords, types.RedemptionRecord{ + Id: item.Id, + RedemptionCodeId: item.RedemptionCodeId, + UserId: item.UserId, + SubscribeId: item.SubscribeId, + UnitTime: item.UnitTime, + Quantity: item.Quantity, + RedeemedAt: item.RedeemedAt.Unix(), + CreatedAt: item.CreatedAt.Unix(), + }) + } + + return &types.GetRedemptionRecordListResponse{ + Total: total, + List: redemptionRecords, + }, nil +} diff --git a/internal/logic/admin/redemption/toggleRedemptionCodeStatusLogic.go b/internal/logic/admin/redemption/toggleRedemptionCodeStatusLogic.go new file mode 100644 index 0000000..b92bf2a --- /dev/null +++ b/internal/logic/admin/redemption/toggleRedemptionCodeStatusLogic.go @@ -0,0 +1,55 @@ +package redemption + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +type ToggleRedemptionCodeStatusLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Toggle redemption code status +func NewToggleRedemptionCodeStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ToggleRedemptionCodeStatusLogic { + return &ToggleRedemptionCodeStatusLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ToggleRedemptionCodeStatusLogic) ToggleRedemptionCodeStatus(req *types.ToggleRedemptionCodeStatusRequest) error { + // Find redemption code + codeInfo, err := l.svcCtx.RedemptionCodeModel.FindOne(l.ctx, req.Id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + l.Errorw("[ToggleRedemptionCodeStatus] Redemption code not found", logger.Field("id", req.Id)) + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "redemption code not found") + } + l.Errorw("[ToggleRedemptionCodeStatus] Database Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find redemption code error: %v", err.Error()) + } + + // Update status + codeInfo.Status = req.Status + + err = l.svcCtx.RedemptionCodeModel.Update(l.ctx, codeInfo) + if err != nil { + l.Errorw("[ToggleRedemptionCodeStatus] Database Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update redemption code status error: %v", err.Error()) + } + + l.Infow("[ToggleRedemptionCodeStatus] Successfully toggled redemption code status", + logger.Field("id", req.Id), + logger.Field("status", req.Status)) + + return nil +} diff --git a/internal/logic/admin/redemption/updateRedemptionCodeLogic.go b/internal/logic/admin/redemption/updateRedemptionCodeLogic.go new file mode 100644 index 0000000..fab6102 --- /dev/null +++ b/internal/logic/admin/redemption/updateRedemptionCodeLogic.go @@ -0,0 +1,65 @@ +package redemption + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type UpdateRedemptionCodeLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Update redemption code +func NewUpdateRedemptionCodeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateRedemptionCodeLogic { + return &UpdateRedemptionCodeLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *UpdateRedemptionCodeLogic) UpdateRedemptionCode(req *types.UpdateRedemptionCodeRequest) error { + redemptionCode, err := l.svcCtx.RedemptionCodeModel.FindOne(l.ctx, req.Id) + if err != nil { + l.Errorw("[UpdateRedemptionCode] Find Redemption Code Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find redemption code error: %v", err.Error()) + } + + // Code is not allowed to be modified + if req.TotalCount != 0 { + // Total count cannot be less than used count + if req.TotalCount < redemptionCode.UsedCount { + l.Errorw("[UpdateRedemptionCode] Total count cannot be less than used count", + logger.Field("total_count", req.TotalCount), + logger.Field("used_count", redemptionCode.UsedCount)) + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), + "total count cannot be less than used count: total_count=%d, used_count=%d", + req.TotalCount, redemptionCode.UsedCount) + } + redemptionCode.TotalCount = req.TotalCount + } + if req.SubscribePlan != 0 { + redemptionCode.SubscribePlan = req.SubscribePlan + } + if req.UnitTime != "" { + redemptionCode.UnitTime = req.UnitTime + } + if req.Quantity != 0 { + redemptionCode.Quantity = req.Quantity + } + + err = l.svcCtx.RedemptionCodeModel.Update(l.ctx, redemptionCode) + if err != nil { + l.Errorw("[UpdateRedemptionCode] Database Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update redemption code error: %v", err.Error()) + } + + return nil +} diff --git a/internal/logic/admin/server/createServerLogic.go b/internal/logic/admin/server/createServerLogic.go index 2ab1993..021a325 100644 --- a/internal/logic/admin/server/createServerLogic.go +++ b/internal/logic/admin/server/createServerLogic.go @@ -99,6 +99,10 @@ func (l *CreateServerLogic) CreateServer(req *types.CreateServerRequest) error { } else { data.City = result.City data.Country = result.Country + data.Latitude = result.Latitude + data.Longitude = result.Longitude + data.LatitudeCenter = result.LatitudeCenter + data.LongitudeCenter = result.LongitudeCenter } } err = l.svcCtx.NodeModel.InsertServer(l.ctx, &data) diff --git a/internal/logic/admin/server/updateServerLogic.go b/internal/logic/admin/server/updateServerLogic.go index f419130..f53a19d 100644 --- a/internal/logic/admin/server/updateServerLogic.go +++ b/internal/logic/admin/server/updateServerLogic.go @@ -39,7 +39,7 @@ func (l *UpdateServerLogic) UpdateServer(req *types.UpdateServerRequest) error { data.Country = req.Country data.City = req.City // only update address when it's different - if req.Address != data.Address { + if req.Address != data.Address || (data.Country == "" || req.Country == "") { // query server ip location result, err := ip.GetRegionByIp(req.Address) if err != nil { @@ -47,6 +47,10 @@ func (l *UpdateServerLogic) UpdateServer(req *types.UpdateServerRequest) error { } else { data.City = result.City data.Country = result.Country + data.Latitude = result.Latitude + data.Longitude = result.Longitude + data.LatitudeCenter = result.LatitudeCenter + data.LongitudeCenter = result.LongitudeCenter } // update address data.Address = req.Address diff --git a/internal/logic/admin/user/deleteUserSubscribeLogic.go b/internal/logic/admin/user/deleteUserSubscribeLogic.go index 397299d..209c23e 100644 --- a/internal/logic/admin/user/deleteUserSubscribeLogic.go +++ b/internal/logic/admin/user/deleteUserSubscribeLogic.go @@ -48,5 +48,9 @@ func (l *DeleteUserSubscribeLogic) DeleteUserSubscribe(req *types.DeleteUserSubs l.Errorw("failed to clear subscribe cache", logger.Field("error", err.Error()), logger.Field("subscribeId", userSubscribe.SubscribeId)) return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "failed to clear subscribe cache: %v", err.Error()) } + if err = l.svcCtx.NodeModel.ClearServerAllCache(l.ctx); err != nil { + l.Errorf("ClearServerAllCache error: %v", err.Error()) + return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "failed to clear server cache: %v", err.Error()) + } return nil } diff --git a/internal/logic/admin/user/updateUserSubscribeLogic.go b/internal/logic/admin/user/updateUserSubscribeLogic.go index 9d92ce5..23c2d2f 100644 --- a/internal/logic/admin/user/updateUserSubscribeLogic.go +++ b/internal/logic/admin/user/updateUserSubscribeLogic.go @@ -69,5 +69,10 @@ func (l *UpdateUserSubscribeLogic) UpdateUserSubscribe(req *types.UpdateUserSubs l.Errorw("failed to clear subscribe cache", logger.Field("error", err.Error()), logger.Field("subscribeId", userSub.SubscribeId)) return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "failed to clear subscribe cache: %v", err.Error()) } + + if err = l.svcCtx.NodeModel.ClearServerAllCache(l.ctx); err != nil { + l.Errorf("ClearServerAllCache error: %v", err.Error()) + return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "failed to clear server cache: %v", err.Error()) + } return nil } diff --git a/internal/logic/auth/bindDeviceLogic.go b/internal/logic/auth/bindDeviceLogic.go index 19b0666..3aedd6f 100644 --- a/internal/logic/auth/bindDeviceLogic.go +++ b/internal/logic/auth/bindDeviceLogic.go @@ -2,6 +2,7 @@ package auth import ( "context" + "time" "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" @@ -88,6 +89,36 @@ func (l *BindDeviceLogic) createDeviceForUser(identifier, ip, userAgent string, logger.Field("user_id", userId), ) + // Check device limit + deviceLimit := l.svcCtx.Config.Register.DeviceLimit + if deviceLimit > 0 { + // Count current user's devices + var deviceCount int64 + if err := l.svcCtx.DB.Model(&user.Device{}).Where("user_id = ?", userId).Count(&deviceCount).Error; err != nil { + l.Errorw("failed to count user devices", + logger.Field("user_id", userId), + logger.Field("error", err.Error()), + ) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count devices failed: %v", err.Error()) + } + + // Check if limit reached + if deviceCount >= deviceLimit { + l.Errorw("device limit reached", + logger.Field("user_id", userId), + logger.Field("device_count", deviceCount), + logger.Field("device_limit", deviceLimit), + ) + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "device limit reached: maximum %d devices allowed", deviceLimit) + } + + l.Infow("device limit check passed", + logger.Field("user_id", userId), + logger.Field("device_count", deviceCount), + logger.Field("device_limit", deviceLimit), + ) + } + err := l.svcCtx.UserModel.Transaction(l.ctx, func(db *gorm.DB) error { // Create device auth method authMethod := &user.AuthMethods{ @@ -107,8 +138,9 @@ func (l *BindDeviceLogic) createDeviceForUser(identifier, ip, userAgent string, // Create device record deviceInfo := &user.Device{ - Ip: ip, - UserId: userId, + Ip: ip, + UserId: userId, + UserAgent: userAgent, Identifier: identifier, Enabled: true, @@ -146,10 +178,87 @@ func (l *BindDeviceLogic) createDeviceForUser(identifier, ip, userAgent string, func (l *BindDeviceLogic) rebindDeviceToNewUser(deviceInfo *user.Device, ip, userAgent string, newUserId int64) error { oldUserId := deviceInfo.UserId - err := l.svcCtx.UserModel.Transaction(l.ctx, func(db *gorm.DB) error { - // Check if old user has other auth methods besides device - var authMethods []user.AuthMethods - if err := db.Where("user_id = ?", oldUserId).Find(&authMethods).Error; err != nil { + // Check device limit for new user + deviceLimit := l.svcCtx.Config.Register.DeviceLimit + if deviceLimit > 0 { + // Count new user's current devices (excluding the one being rebound) + var deviceCount int64 + if err := l.svcCtx.DB.Model(&user.Device{}).Where("user_id = ? AND id != ?", newUserId, deviceInfo.Id).Count(&deviceCount).Error; err != nil { + l.Errorw("failed to count new user devices", + logger.Field("user_id", newUserId), + logger.Field("error", err.Error()), + ) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count devices failed: %v", err.Error()) + } + + // Check if limit reached + if deviceCount >= deviceLimit { + l.Errorw("device limit reached for new user", + logger.Field("user_id", newUserId), + logger.Field("device_count", deviceCount), + logger.Field("device_limit", deviceLimit), + ) + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "device limit reached: maximum %d devices allowed", deviceLimit) + } + + l.Infow("device limit check passed for rebinding", + logger.Field("user_id", newUserId), + logger.Field("device_count", deviceCount), + logger.Field("device_limit", deviceLimit), + ) + } + + var users []*user.User + err := l.svcCtx.DB.Where("id in (?)", []int64{oldUserId, newUserId}).Find(&users).Error + if err != nil { + l.Errorw("failed to query users for rebinding", + logger.Field("old_user_id", oldUserId), + logger.Field("new_user_id", newUserId), + logger.Field("error", err.Error()), + ) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query users failed: %v", err) + } + err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error { + //检查旧设备是否存在认证方式 + var authMethod user.AuthMethods + err := tx.Where("auth_type = ? AND auth_identifier = ?", "device", deviceInfo.Identifier).Find(&authMethod).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + l.Errorw("failed to query device auth method", + logger.Field("identifier", deviceInfo.Identifier), + logger.Field("error", err.Error()), + ) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query device auth method failed: %v", err) + } + + //未找到设备认证方式信息,创建新的设备认证方式 + if err != nil { + authMethod = user.AuthMethods{ + UserId: newUserId, + AuthType: "device", + AuthIdentifier: deviceInfo.Identifier, + Verified: true, + } + logger.Infof("create auth method: %v", authMethod) + if err := tx.Create(&authMethod).Error; err != nil { + l.Errorw("failed to create device auth method", logger.Field("new_user_id", newUserId), + logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create device auth method failed: %v", err) + } + } else { + //更新设备认证方式的用户ID为新用户ID + authMethod.UserId = newUserId + if err := tx.Save(&authMethod).Error; err != nil { + l.Errorw("failed to update device auth method", + logger.Field("identifier", deviceInfo.Identifier), + logger.Field("error", err.Error()), + ) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update device auth method failed: %v", err) + } + } + + //检查旧用户是否还有其他认证方式 + var count int64 + if err := tx.Model(&user.AuthMethods{}).Where("user_id = ?", oldUserId).Count(&count).Error; err != nil { l.Errorw("failed to query auth methods for old user", logger.Field("old_user_id", oldUserId), logger.Field("error", err.Error()), @@ -157,60 +266,113 @@ func (l *BindDeviceLogic) rebindDeviceToNewUser(deviceInfo *user.Device, ip, use return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query auth methods failed: %v", err) } - // Count non-device auth methods - nonDeviceAuthCount := 0 - for _, auth := range authMethods { - if auth.AuthType != "device" { - nonDeviceAuthCount++ + //如果没有其他认证方式,禁用旧用户账号 + if count < 1 { + //检查设备下是否有套餐,有套餐。就检查即将绑定过去的所有账户是否有套餐,如果有,那么检查两个套餐是否一致。如果一致就将即将删除的用户套餐,时间叠加到我绑定过去的用户套餐上面(如果套餐已过期就忽略)。新绑定设备的账户上套餐不一致或者不存在直接将套餐换绑即可 + var oldUserSubscribes []user.Subscribe + err = tx.Where("user_id = ? AND status IN ?", oldUserId, []int64{0, 1}).Find(&oldUserSubscribes).Error + if err != nil { + l.Errorw("failed to query old user subscribes", + logger.Field("old_user_id", oldUserId), + logger.Field("error", err.Error()), + ) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query old user subscribes failed: %v", err) } - } - // Only disable old user if they have no other auth methods - if nonDeviceAuthCount == 0 { - falseVal := false - if err := db.Model(&user.User{}).Where("id = ?", oldUserId).Update("enable", &falseVal).Error; err != nil { + if len(oldUserSubscribes) > 0 { + l.Infow("processing old user subscribes", + logger.Field("old_user_id", oldUserId), + logger.Field("new_user_id", newUserId), + logger.Field("subscribe_count", len(oldUserSubscribes)), + ) + + for _, oldSub := range oldUserSubscribes { + // 检查新用户是否有相同套餐ID的订阅 + var newUserSub user.Subscribe + err = tx.Where("user_id = ? AND subscribe_id = ? AND status IN ?", newUserId, oldSub.SubscribeId, []int64{0, 1}).First(&newUserSub).Error + + if err != nil { + // 新用户没有该套餐,直接换绑 + oldSub.UserId = newUserId + if err := tx.Save(&oldSub).Error; err != nil { + l.Errorw("failed to rebind subscribe to new user", + logger.Field("subscribe_id", oldSub.Id), + logger.Field("old_user_id", oldUserId), + logger.Field("new_user_id", newUserId), + logger.Field("error", err.Error()), + ) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "rebind subscribe failed: %v", err) + } + l.Infow("rebind subscribe to new user", + logger.Field("subscribe_id", oldSub.Id), + logger.Field("new_user_id", newUserId), + ) + } else { + // 新用户已有该套餐,检查旧套餐是否过期 + now := time.Now() + if oldSub.ExpireTime.After(now) { + // 旧套餐未过期,叠加剩余时间 + remainingDuration := oldSub.ExpireTime.Sub(now) + if newUserSub.ExpireTime.After(now) { + // 新套餐未过期,叠加时间 + newUserSub.ExpireTime = newUserSub.ExpireTime.Add(remainingDuration) + } else { + newUserSub.ExpireTime = time.Now().Add(remainingDuration) + } + if err := tx.Save(&newUserSub).Error; err != nil { + l.Errorw("failed to update subscribe expire time", + logger.Field("subscribe_id", newUserSub.Id), + logger.Field("error", err.Error()), + ) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update subscribe expire time failed: %v", err) + } + l.Infow("merged subscribe time", + logger.Field("subscribe_id", newUserSub.Id), + logger.Field("new_expire_time", newUserSub.ExpireTime), + ) + } else { + l.Infow("old subscribe expired, skip merge", + logger.Field("subscribe_id", oldSub.Id), + logger.Field("expire_time", oldSub.ExpireTime), + ) + } + // 删除旧用户的套餐 + if err := tx.Delete(&oldSub).Error; err != nil { + l.Errorw("failed to delete old subscribe", + logger.Field("subscribe_id", oldSub.Id), + logger.Field("error", err.Error()), + ) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete old subscribe failed: %v", err) + } + } + } + } + + if err := tx.Model(&user.User{}).Where("id = ?", oldUserId).Delete(&user.User{}).Error; err != nil { l.Errorw("failed to disable old user", logger.Field("old_user_id", oldUserId), logger.Field("error", err.Error()), ) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "disable old user failed: %v", err) } - - l.Infow("disabled old user (no other auth methods)", - logger.Field("old_user_id", oldUserId), - ) - } else { - l.Infow("old user has other auth methods, not disabling", - logger.Field("old_user_id", oldUserId), - logger.Field("non_device_auth_count", nonDeviceAuthCount), - ) } - // Update device auth method to new user - if err := db.Model(&user.AuthMethods{}). - Where("auth_type = ? AND auth_identifier = ?", "device", deviceInfo.Identifier). - Update("user_id", newUserId).Error; err != nil { - l.Errorw("failed to update device auth method", - logger.Field("identifier", deviceInfo.Identifier), - logger.Field("error", err.Error()), - ) - return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update device auth method failed: %v", err) - } + l.Infow("disabled old user (no other auth methods)", + logger.Field("old_user_id", oldUserId), + ) - // Update device record + // 更新设备绑定的用户id deviceInfo.UserId = newUserId deviceInfo.Ip = ip deviceInfo.UserAgent = userAgent deviceInfo.Enabled = true - - if err := db.Save(deviceInfo).Error; err != nil { + if err := tx.Save(deviceInfo).Error; err != nil { l.Errorw("failed to update device", logger.Field("identifier", deviceInfo.Identifier), logger.Field("error", err.Error()), ) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update device failed: %v", err) } - return nil }) @@ -224,6 +386,15 @@ func (l *BindDeviceLogic) rebindDeviceToNewUser(deviceInfo *user.Device, ip, use return err } + err = l.svcCtx.UserModel.ClearUserCache(l.ctx, users...) + if err != nil { + l.Errorw("failed to clear user cache after rebinding", + logger.Field("old_user_id", oldUserId), + logger.Field("new_user_id", newUserId), + logger.Field("error", err.Error()), + ) + } + l.Infow("device rebound successfully", logger.Field("identifier", deviceInfo.Identifier), logger.Field("old_user_id", oldUserId), diff --git a/internal/logic/auth/deviceLoginLogic.go b/internal/logic/auth/deviceLoginLogic.go index 2e2b6ae..985813e 100644 --- a/internal/logic/auth/deviceLoginLogic.go +++ b/internal/logic/auth/deviceLoginLogic.go @@ -71,6 +71,9 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ deviceInfo, err := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { + if !registerIpLimit(l.svcCtx, l.ctx, req.IP, "device", req.Identifier) { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.RegisterIPLimit), "register ip limit: %v", req.IP) + } // Device not found, create new user and device userInfo, err = l.registerUserAndDevice(req) if err != nil { @@ -125,6 +128,17 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error()) } + // Store device id in redis + + deviceCacheKey := fmt.Sprintf("%v:%v", config.DeviceCacheKeyKey, req.Identifier) + if err = l.svcCtx.Redis.Set(l.ctx, deviceCacheKey, sessionId, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil { + l.Errorw("set device id error", + logger.Field("user_id", userInfo.Id), + logger.Field("error", err.Error()), + ) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set device id error: %v", err.Error()) + } + loginStatus = true return &types.LoginResponse{ Token: token, @@ -141,6 +155,7 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest) err := l.svcCtx.UserModel.Transaction(l.ctx, func(db *gorm.DB) error { // Create new user userInfo = &user.User{ + Salt: "default", OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase, } if err := db.Create(userInfo).Error; err != nil { @@ -289,5 +304,8 @@ func (l *DeviceLoginLogic) activeTrial(userId int64, db *gorm.DB) error { logger.Field("traffic", sub.Traffic), ) + if clearErr := l.svcCtx.NodeModel.ClearServerAllCache(l.ctx); clearErr != nil { + l.Errorf("ClearServerAllCache error: %v", clearErr.Error()) + } return nil } diff --git a/internal/logic/auth/registerLimitLogic.go b/internal/logic/auth/registerLimitLogic.go new file mode 100644 index 0000000..40ce3ac --- /dev/null +++ b/internal/logic/auth/registerLimitLogic.go @@ -0,0 +1,45 @@ +package auth + +import ( + "context" + "fmt" + "time" + + "github.com/perfect-panel/server/internal/config" + "github.com/perfect-panel/server/internal/svc" + "go.uber.org/zap" +) + +func registerIpLimit(svcCtx *svc.ServiceContext, ctx context.Context, registerIp, authType, account string) (isOk bool) { + if !svcCtx.Config.Register.EnableIpRegisterLimit { + return true + } + cacheKey := fmt.Sprintf("%s%s:*", config.RegisterIpKeyPrefix, registerIp) + var cacheKeys []string + var cursor uint64 + for { + keys, newCursor, err := svcCtx.Redis.Scan(ctx, 0, cacheKey, 100).Result() + if err != nil { + zap.S().Errorf("[registerIpLimit] Err: %v", err) + return true + } + if len(keys) > 0 { + cacheKeys = append(cacheKeys, keys...) + } + cursor = newCursor + if cursor == 0 { + break + } + } + + defer func() { + key := fmt.Sprintf("%s%s:%s:%s", config.RegisterIpKeyPrefix, registerIp, authType, account) + if err := svcCtx.Redis.Set(ctx, key, account, time.Minute*time.Duration(svcCtx.Config.Register.IpRegisterLimitDuration)).Err(); err != nil { + zap.S().Errorf("[registerIpLimit] Set Err: %v", err) + } + }() + if len(cacheKeys) < int(svcCtx.Config.Register.IpRegisterLimit) { + return true + } + return false +} diff --git a/internal/logic/auth/resetPasswordLogic.go b/internal/logic/auth/resetPasswordLogic.go index aef245a..22db2c9 100644 --- a/internal/logic/auth/resetPasswordLogic.go +++ b/internal/logic/auth/resetPasswordLogic.go @@ -121,8 +121,8 @@ func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (res // Don't fail register if device binding fails, just log the error } } - if l.ctx.Value(constant.LoginType) != nil { - req.LoginType = l.ctx.Value(constant.LoginType).(string) + if l.ctx.Value(constant.CtxLoginType) != nil { + req.LoginType = l.ctx.Value(constant.CtxLoginType).(string) } // Generate session id sessionId := uuidx.NewUUID().String() @@ -133,7 +133,8 @@ func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (res l.svcCtx.Config.JwtAuth.AccessExpire, jwt.WithOption("UserId", userInfo.Id), jwt.WithOption("SessionId", sessionId), - jwt.WithOption("LoginType", req.LoginType), + jwt.WithOption("identifier", req.Identifier), + jwt.WithOption("CtxLoginType", req.LoginType), ) if err != nil { l.Logger.Error("[UserLogin] token generate error", logger.Field("error", err.Error())) diff --git a/internal/logic/auth/telephoneLoginLogic.go b/internal/logic/auth/telephoneLoginLogic.go index 8a54ff5..3a8655c 100644 --- a/internal/logic/auth/telephoneLoginLogic.go +++ b/internal/logic/auth/telephoneLoginLogic.go @@ -10,7 +10,6 @@ import ( "github.com/perfect-panel/server/internal/config" "github.com/perfect-panel/server/internal/logic/common" "github.com/perfect-panel/server/internal/model/log" - "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/constant" @@ -48,7 +47,22 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r return nil, errors.Wrapf(xerr.NewErrCode(xerr.SmsNotEnabled), "sms login is not enabled") } loginStatus := false - var userInfo *user.User + + authMethodInfo, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "mobile", phoneNumber) + if err != nil { + if errors.As(err, gorm.ErrRecordNotFound) { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "user telephone not exist: %v", req.Telephone) + } + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user info failed: %v", err.Error()) + } + + userInfo, err := l.svcCtx.UserModel.FindOne(l.ctx, authMethodInfo.UserId) + if err != nil { + if errors.As(err, gorm.ErrRecordNotFound) { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "user telephone not exist: %v", req.Telephone) + } + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user info failed: %v", err.Error()) + } // Record login status defer func(svcCtx *svc.ServiceContext) { if userInfo.Id != 0 { @@ -76,22 +90,6 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r } }(l.svcCtx) - authMethodInfo, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "mobile", phoneNumber) - if err != nil { - if errors.As(err, gorm.ErrRecordNotFound) { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "user telephone not exist: %v", req.Telephone) - } - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user info failed: %v", err.Error()) - } - - userInfo, err = l.svcCtx.UserModel.FindOne(l.ctx, authMethodInfo.UserId) - if err != nil { - if errors.As(err, gorm.ErrRecordNotFound) { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "user telephone not exist: %v", req.Telephone) - } - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user info failed: %v", err.Error()) - } - if req.Password == "" && req.TelephoneCode == "" { return nil, xerr.NewErrCodeMsg(xerr.InvalidParams, "password and telephone code is empty") } @@ -137,8 +135,8 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r } } - if l.ctx.Value(constant.LoginType) != nil { - req.LoginType = l.ctx.Value(constant.LoginType).(string) + if l.ctx.Value(constant.CtxLoginType) != nil { + req.LoginType = l.ctx.Value(constant.CtxLoginType).(string) } // Generate session id @@ -150,7 +148,8 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r l.svcCtx.Config.JwtAuth.AccessExpire, jwt.WithOption("UserId", userInfo.Id), jwt.WithOption("SessionId", sessionId), - jwt.WithOption("LoginType", req.LoginType), + jwt.WithOption("identifier", req.Identifier), + jwt.WithOption("CtxLoginType", req.LoginType), ) if err != nil { l.Logger.Error("[UserLogin] token generate error", logger.Field("error", err.Error())) diff --git a/internal/logic/auth/telephoneResetPasswordLogic.go b/internal/logic/auth/telephoneResetPasswordLogic.go index 5cb47cc..0848436 100644 --- a/internal/logic/auth/telephoneResetPasswordLogic.go +++ b/internal/logic/auth/telephoneResetPasswordLogic.go @@ -2,6 +2,7 @@ package auth import ( "context" + "encoding/json" "fmt" "time" @@ -43,19 +44,32 @@ func (l *TelephoneResetPasswordLogic) TelephoneResetPassword(req *types.Telephon return nil, errors.Wrapf(xerr.NewErrCode(xerr.TelephoneError), "Invalid phone number") } - if l.svcCtx.Config.Mobile.Enable { + if !l.svcCtx.Config.Mobile.Enable { return nil, errors.Wrapf(xerr.NewErrCode(xerr.SmsNotEnabled), "sms login is not enabled") } // if the email verification is enabled, the verification code is required - cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeTelephoneCacheKey, constant.Security, phoneNumber) + cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeTelephoneCacheKey, constant.ParseVerifyType(uint8(constant.Security)), phoneNumber) + l.Logger.Infof("TelephoneResetPassword cacheKey: %s, code: %s", cacheKey, code) value, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result() if err != nil { l.Errorw("Redis Error", logger.Field("error", err.Error()), logger.Field("cacheKey", cacheKey)) return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error") } + l.Logger.Infof("TelephoneResetPassword cacheKey: %s, code: %s,value : %s", cacheKey, code, value) + if value == "" { + l.Errorf("TelephoneResetPassword value empty: %s", value) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error") + } + + var payload CacheKeyPayload + if err := json.Unmarshal([]byte(value), &payload); err != nil { + l.Errorf("TelephoneResetPassword Unmarshal Error: %s", err.Error()) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error") + } - if value != code { + if payload.Code != code { + l.Errorf("TelephoneResetPassword code: %s, code: %s", code, payload.Code) return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error") } @@ -96,8 +110,8 @@ func (l *TelephoneResetPasswordLogic) TelephoneResetPassword(req *types.Telephon // Don't fail register if device binding fails, just log the error } } - if l.ctx.Value(constant.LoginType) != nil { - req.LoginType = l.ctx.Value(constant.LoginType).(string) + if l.ctx.Value(constant.CtxLoginType) != nil { + req.LoginType = l.ctx.Value(constant.CtxLoginType).(string) } // Generate session id sessionId := uuidx.NewUUID().String() @@ -108,7 +122,8 @@ func (l *TelephoneResetPasswordLogic) TelephoneResetPassword(req *types.Telephon l.svcCtx.Config.JwtAuth.AccessExpire, jwt.WithOption("UserId", userInfo.Id), jwt.WithOption("SessionId", sessionId), - jwt.WithOption("LoginType", req.LoginType), + jwt.WithOption("identifier", req.Identifier), + jwt.WithOption("CtxLoginType", req.LoginType), ) if err != nil { l.Errorw("[UserLogin] token generate error", logger.Field("error", err.Error())) diff --git a/internal/logic/auth/telephoneUserRegisterLogic.go b/internal/logic/auth/telephoneUserRegisterLogic.go index af16811..ac54796 100644 --- a/internal/logic/auth/telephoneUserRegisterLogic.go +++ b/internal/logic/auth/telephoneUserRegisterLogic.go @@ -102,7 +102,9 @@ func (l *TelephoneUserRegisterLogic) TelephoneUserRegister(req *types.TelephoneR return nil, errors.Wrapf(xerr.NewErrCode(xerr.InviteCodeError), "invite code is invalid") } } - + if !registerIpLimit(l.svcCtx, l.ctx, req.IP, "mobile", phoneNumber) { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.RegisterIPLimit), "register ip limit: %v", req.IP) + } // Generate password pwd := tool.EncodePassWord(req.Password) userInfo := &user.User{ @@ -152,8 +154,8 @@ func (l *TelephoneUserRegisterLogic) TelephoneUserRegister(req *types.TelephoneR // Don't fail register if device binding fails, just log the error } } - if l.ctx.Value(constant.LoginType) != nil { - req.LoginType = l.ctx.Value(constant.LoginType).(string) + if l.ctx.Value(constant.CtxLoginType) != nil { + req.LoginType = l.ctx.Value(constant.CtxLoginType).(string) } // Generate session id sessionId := uuidx.NewUUID().String() @@ -164,7 +166,8 @@ func (l *TelephoneUserRegisterLogic) TelephoneUserRegister(req *types.TelephoneR l.svcCtx.Config.JwtAuth.AccessExpire, jwt.WithOption("UserId", userInfo.Id), jwt.WithOption("SessionId", sessionId), - jwt.WithOption("LoginType", req.LoginType), + jwt.WithOption("identifier", req.Identifier), + jwt.WithOption("CtxLoginType", req.LoginType), ) if err != nil { l.Logger.Error("[UserLogin] token generate error", logger.Field("error", err.Error())) @@ -245,5 +248,12 @@ func (l *TelephoneUserRegisterLogic) activeTrial(uid int64) error { UUID: uuidx.NewUUID().String(), Status: 1, } - return l.svcCtx.UserModel.InsertSubscribe(l.ctx, userSub) + err = l.svcCtx.UserModel.InsertSubscribe(l.ctx, userSub) + if err != nil { + return err + } + if clearErr := l.svcCtx.NodeModel.ClearServerAllCache(l.ctx); clearErr != nil { + l.Errorf("ClearServerAllCache error: %v", clearErr.Error()) + } + return err } diff --git a/internal/logic/auth/userLoginLogic.go b/internal/logic/auth/userLoginLogic.go index bc3c2fa..4e6fac2 100644 --- a/internal/logic/auth/userLoginLogic.go +++ b/internal/logic/auth/userLoginLogic.go @@ -97,8 +97,8 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log // Don't fail login if device binding fails, just log the error } } - if l.ctx.Value(constant.LoginType) != nil { - req.LoginType = l.ctx.Value(constant.LoginType).(string) + if l.ctx.Value(constant.CtxLoginType) != nil { + req.LoginType = l.ctx.Value(constant.CtxLoginType).(string) } // Generate session id sessionId := uuidx.NewUUID().String() @@ -109,7 +109,8 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log l.svcCtx.Config.JwtAuth.AccessExpire, jwt.WithOption("UserId", userInfo.Id), jwt.WithOption("SessionId", sessionId), - jwt.WithOption("LoginType", req.LoginType), + jwt.WithOption("identifier", req.Identifier), + jwt.WithOption("CtxLoginType", req.LoginType), ) if err != nil { l.Logger.Error("[UserLogin] token generate error", logger.Field("error", err.Error())) diff --git a/internal/logic/auth/userRegisterLogic.go b/internal/logic/auth/userRegisterLogic.go index 8b622c0..e128a69 100644 --- a/internal/logic/auth/userRegisterLogic.go +++ b/internal/logic/auth/userRegisterLogic.go @@ -89,6 +89,10 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp * return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "user email deleted: %v", req.Email) } + if !registerIpLimit(l.svcCtx, l.ctx, req.IP, "email", req.Email) { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.RegisterIPLimit), "register ip limit: %v", req.IP) + } + // Generate password pwd := tool.EncodePassWord(req.Password) userInfo := &user.User{ @@ -141,8 +145,8 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp * // Don't fail register if device binding fails, just log the error } } - if l.ctx.Value(constant.LoginType) != nil { - req.LoginType = l.ctx.Value(constant.LoginType).(string) + if l.ctx.Value(constant.CtxLoginType) != nil { + req.LoginType = l.ctx.Value(constant.CtxLoginType).(string) } // Generate session id sessionId := uuidx.NewUUID().String() diff --git a/internal/logic/public/portal/purchaseCheckoutLogic.go b/internal/logic/public/portal/purchaseCheckoutLogic.go index 105e1dc..62c2c11 100644 --- a/internal/logic/public/portal/purchaseCheckoutLogic.go +++ b/internal/logic/public/portal/purchaseCheckoutLogic.go @@ -363,6 +363,7 @@ func (l *PurchaseCheckoutLogic) CryptoSaaSPayment(config *payment.Payment, info } notifyUrl = notifyUrl + "/v1/notify/" + config.Platform + "/" + config.Token } + // Create payment URL for user redirection url := client.CreatePayUrl(epay.Order{ Name: l.svcCtx.Config.Site.SiteName, diff --git a/internal/logic/public/redemption/redeemCodeLogic.go b/internal/logic/public/redemption/redeemCodeLogic.go new file mode 100644 index 0000000..a00bcd9 --- /dev/null +++ b/internal/logic/public/redemption/redeemCodeLogic.go @@ -0,0 +1,262 @@ +package redemption + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/perfect-panel/server/internal/model/redemption" + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/snowflake" + "github.com/perfect-panel/server/pkg/uuidx" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" +) + +type RedeemCodeLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Redeem code +func NewRedeemCodeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RedeemCodeLogic { + return &RedeemCodeLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *RedeemCodeLogic) RedeemCode(req *types.RedeemCodeRequest) (resp *types.RedeemCodeResponse, err error) { + // Get user from context + u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) + if !ok { + logger.Error("current user is not found in context") + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") + } + + // Find redemption code by code + redemptionCode, err := l.svcCtx.RedemptionCodeModel.FindOneByCode(l.ctx, req.Code) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + l.Errorw("[RedeemCode] Redemption code not found", logger.Field("code", req.Code)) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "redemption code not found") + } + l.Errorw("[RedeemCode] Database Error", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find redemption code error: %v", err.Error()) + } + + // Check if redemption code is enabled + if redemptionCode.Status != 1 { + l.Errorw("[RedeemCode] Redemption code is disabled", + logger.Field("code", req.Code), + logger.Field("status", redemptionCode.Status)) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "redemption code is disabled") + } + + // Check if redemption code has remaining count + if redemptionCode.TotalCount > 0 && redemptionCode.UsedCount >= redemptionCode.TotalCount { + l.Errorw("[RedeemCode] Redemption code has been fully used", + logger.Field("code", req.Code), + logger.Field("total_count", redemptionCode.TotalCount), + logger.Field("used_count", redemptionCode.UsedCount)) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "redemption code has been fully used") + } + + // Check if user has already redeemed this code + userRecords, err := l.svcCtx.RedemptionRecordModel.FindByUserId(l.ctx, u.Id) + if err != nil { + l.Errorw("[RedeemCode] Database Error", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find redemption records error: %v", err.Error()) + } + for _, record := range userRecords { + if record.RedemptionCodeId == redemptionCode.Id { + l.Errorw("[RedeemCode] User has already redeemed this code", + logger.Field("user_id", u.Id), + logger.Field("code", req.Code)) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "you have already redeemed this code") + } + } + + // Find subscribe plan from redemption code + subscribePlan, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, redemptionCode.SubscribePlan) + if err != nil { + l.Errorw("[RedeemCode] Subscribe plan not found", + logger.Field("subscribe_plan", redemptionCode.SubscribePlan), + logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "subscribe plan not found") + } + + // Check if subscribe plan is available + if !*subscribePlan.Sell { + l.Errorw("[RedeemCode] Subscribe plan is not available", + logger.Field("subscribe_plan", redemptionCode.SubscribePlan)) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.SubscribeNotAvailable), "subscribe plan is not available") + } + + // Start transaction + err = l.svcCtx.RedemptionCodeModel.Transaction(l.ctx, func(tx *gorm.DB) error { + // Find user's existing subscribe for this plan + var existingSubscribe *user.SubscribeDetails + userSubscribes, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, u.Id, 0, 1) + if err == nil { + for _, us := range userSubscribes { + if us.SubscribeId == redemptionCode.SubscribePlan { + existingSubscribe = us + break + } + } + } + + now := time.Now() + + if existingSubscribe != nil { + // Extend existing subscribe + var newExpireTime time.Time + if existingSubscribe.ExpireTime.After(now) { + newExpireTime = existingSubscribe.ExpireTime + } else { + newExpireTime = now + } + + // Calculate duration based on redemption code + duration, err := calculateDuration(redemptionCode.UnitTime, redemptionCode.Quantity) + if err != nil { + l.Errorw("[RedeemCode] Calculate duration error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "calculate duration error: %v", err.Error()) + } + newExpireTime = newExpireTime.Add(duration) + + // Update subscribe + existingSubscribe.ExpireTime = newExpireTime + existingSubscribe.Status = 1 + + // Add traffic if needed + if subscribePlan.Traffic > 0 { + existingSubscribe.Traffic = subscribePlan.Traffic * 1024 * 1024 * 1024 + existingSubscribe.Download = 0 + existingSubscribe.Upload = 0 + } + + err = l.svcCtx.UserModel.UpdateSubscribe(l.ctx, &user.Subscribe{ + Id: existingSubscribe.Id, + UserId: existingSubscribe.UserId, + ExpireTime: existingSubscribe.ExpireTime, + Status: existingSubscribe.Status, + Traffic: existingSubscribe.Traffic, + Download: existingSubscribe.Download, + Upload: existingSubscribe.Upload, + }, tx) + if err != nil { + l.Errorw("[RedeemCode] Update subscribe error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update subscribe error: %v", err.Error()) + } + } else { + // Create new subscribe + expireTime, traffic, err := calculateSubscribeTimeAndTraffic(redemptionCode.UnitTime, redemptionCode.Quantity, subscribePlan.Traffic) + if err != nil { + l.Errorw("[RedeemCode] Calculate subscribe time and traffic error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "calculate subscribe time and traffic error: %v", err.Error()) + } + + newSubscribe := &user.Subscribe{ + Id: snowflake.GetID(), + UserId: u.Id, + OrderId: 0, + SubscribeId: redemptionCode.SubscribePlan, + StartTime: now, + ExpireTime: expireTime, + FinishedAt: nil, + Traffic: traffic, + Download: 0, + Upload: 0, + Token: uuidx.SubscribeToken(fmt.Sprintf("redemption:%d:%d", u.Id, time.Now().UnixMilli())), + UUID: uuid.New().String(), + Status: 1, + } + + err = l.svcCtx.UserModel.InsertSubscribe(l.ctx, newSubscribe, tx) + if err != nil { + l.Errorw("[RedeemCode] Insert subscribe error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert subscribe error: %v", err.Error()) + } + } + + // Increment redemption code used count + err = l.svcCtx.RedemptionCodeModel.IncrementUsedCount(l.ctx, redemptionCode.Id) + if err != nil { + l.Errorw("[RedeemCode] Increment used count error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "increment used count error: %v", err.Error()) + } + + // Create redemption record + redemptionRecord := &redemption.RedemptionRecord{ + Id: snowflake.GetID(), + RedemptionCodeId: redemptionCode.Id, + UserId: u.Id, + SubscribeId: redemptionCode.SubscribePlan, + UnitTime: redemptionCode.UnitTime, + Quantity: redemptionCode.Quantity, + RedeemedAt: now, + CreatedAt: now, + } + + err = l.svcCtx.RedemptionRecordModel.Insert(l.ctx, redemptionRecord) + if err != nil { + l.Errorw("[RedeemCode] Insert redemption record error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert redemption record error: %v", err.Error()) + } + + return nil + }) + + if err != nil { + return nil, err + } + + return &types.RedeemCodeResponse{ + Message: "Redemption successful", + }, nil +} + +// calculateDuration calculates time duration based on unit time +func calculateDuration(unitTime string, quantity int64) (time.Duration, error) { + switch unitTime { + case "month": + return time.Duration(quantity*30*24) * time.Hour, nil + case "quarter": + return time.Duration(quantity*90*24) * time.Hour, nil + case "half_year": + return time.Duration(quantity*180*24) * time.Hour, nil + case "year": + return time.Duration(quantity*365*24) * time.Hour, nil + case "day": + return time.Duration(quantity*24) * time.Hour, nil + default: + return time.Duration(quantity*30*24) * time.Hour, nil + } +} + +// calculateSubscribeTimeAndTraffic calculates expire time and traffic based on subscribe plan +func calculateSubscribeTimeAndTraffic(unitTime string, quantity int64, traffic int64) (time.Time, int64, error) { + duration, err := calculateDuration(unitTime, quantity) + if err != nil { + return time.Time{}, 0, err + } + + expireTime := time.Now().Add(duration) + trafficBytes := int64(0) + if traffic > 0 { + trafficBytes = traffic * 1024 * 1024 * 1024 + } + + return expireTime, trafficBytes, nil +} diff --git a/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go b/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go index 2ad05b8..7573d89 100644 --- a/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go +++ b/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go @@ -38,7 +38,7 @@ func (l *QueryUserSubscribeNodeListLogic) QueryUserSubscribeNodeList() (resp *ty return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") } - userSubscribes, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, u.Id, 1, 2) + userSubscribes, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, u.Id, 0, 1, 2, 3) if err != nil { logger.Errorw("failed to query user subscribe", logger.Field("error", err.Error()), logger.Field("user_id", u.Id)) return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "DB_ERROR") @@ -79,7 +79,6 @@ func (l *QueryUserSubscribeNodeListLogic) QueryUserSubscribeNodeList() (resp *ty if l.svcCtx.Config.Register.EnableTrial && l.svcCtx.Config.Register.TrialSubscribe == userSubscribe.SubscribeId { userSubscribeInfo.IsTryOut = true } - resp.List = append(resp.List, userSubscribeInfo) } @@ -137,16 +136,21 @@ func (l *QueryUserSubscribeNodeListLogic) getServers(userSub *user.Subscribe) (u continue } userSubscribeNode := &types.UserSubscribeNodeInfo{ - Id: n.Id, - Name: n.Name, - Uuid: userSub.UUID, - Protocol: n.Protocol, - Port: n.Port, - Address: n.Address, - Tags: strings.Split(n.Tags, ","), - Country: server.Country, - City: server.City, - CreatedAt: n.CreatedAt.Unix(), + Id: n.Id, + Name: n.Name, + Uuid: userSub.UUID, + Protocol: n.Protocol, + Protocols: server.Protocols, + Port: n.Port, + Address: n.Address, + Tags: strings.Split(n.Tags, ","), + Country: server.Country, + City: server.City, + Latitude: server.Latitude, + Longitude: server.Longitude, + LongitudeCenter: server.LongitudeCenter, + LatitudeCenter: server.LatitudeCenter, + CreatedAt: n.CreatedAt.Unix(), } userSubscribeNodes = append(userSubscribeNodes, userSubscribeNode) } diff --git a/internal/logic/public/user/deleteCurrentUserAccountLogic.go b/internal/logic/public/user/deleteCurrentUserAccountLogic.go new file mode 100644 index 0000000..5a4e2be --- /dev/null +++ b/internal/logic/public/user/deleteCurrentUserAccountLogic.go @@ -0,0 +1,86 @@ +package user + +import ( + "context" + "fmt" + + "github.com/perfect-panel/server/internal/config" + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +type DeleteCurrentUserAccountLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Delete Current User Account +func NewDeleteCurrentUserAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteCurrentUserAccountLogic { + return &DeleteCurrentUserAccountLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *DeleteCurrentUserAccountLogic) DeleteCurrentUserAccount() (err error) { + userInfo, exists := l.ctx.Value(constant.CtxKeyUser).(*user.User) + if !exists { + return nil + } + + userInfo, err = l.svcCtx.UserModel.FindOne(l.ctx, userInfo.Id) + if err != nil { + l.Errorw("FindOne Error", logger.Field("error", err)) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find user auth methods failed: %v", err.Error()) + } + + err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error { + //delete user devices + if len(userInfo.UserDevices) > 0 { + for _, device := range userInfo.UserDevices { + if err = l.svcCtx.UserModel.DeleteDevice(l.ctx, device.Id, tx); err != nil { + return err + } + } + } + + // delete user auth methods + if len(userInfo.AuthMethods) > 0 { + for _, authMethod := range userInfo.AuthMethods { + if err = l.svcCtx.UserModel.DeleteUserAuthMethods(l.ctx, userInfo.Id, authMethod.AuthType); err != nil { + return err + } + } + } + + // delete user subscribes + var subscribes []*user.SubscribeDetails + subscribes, err = l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, userInfo.Id) + if err != nil { + return err + } + for _, subscribe := range subscribes { + if err = l.svcCtx.UserModel.DeleteSubscribe(l.ctx, subscribe.Token, tx); err != nil { + return err + } + } + // delete user account + return l.svcCtx.UserModel.BatchDeleteUser(l.ctx, []int64{userInfo.Id}, tx) + }) + if err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "find user auth methods failed: %v", err.Error()) + } + sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, l.ctx.Value(constant.CtxKeySessionID)) + if err = l.svcCtx.Redis.Del(l.ctx, sessionIdCacheKey).Err(); err != nil { + l.Logger.Errorf("delete session id cache failed: %v", err.Error()) + } + return + +} diff --git a/internal/logic/public/user/deviceOnlineStatisticsLogic.go b/internal/logic/public/user/deviceOnlineStatisticsLogic.go new file mode 100644 index 0000000..27ca6dc --- /dev/null +++ b/internal/logic/public/user/deviceOnlineStatisticsLogic.go @@ -0,0 +1,115 @@ +package user + +import ( + "context" + "sort" + "time" + + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/logger" +) + +type DeviceOnlineStatisticsLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Device Online Statistics +func NewDeviceOnlineStatisticsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeviceOnlineStatisticsLogic { + return &DeviceOnlineStatisticsLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *DeviceOnlineStatisticsLogic) DeviceOnlineStatistics() (resp *types.GetDeviceOnlineStatsResponse, err error) { + u := l.ctx.Value(constant.CtxKeyUser).(*user.User) + //获取历史最长在线时间 + var OnlineSeconds int64 + if err := l.svcCtx.DB.Model(user.DeviceOnlineRecord{}).Where("user_id = ?", u.Id).Select("online_seconds").Order("online_seconds desc").Limit(1).Scan(&OnlineSeconds).Error; err != nil { + l.Logger.Error(err) + } + + //获取历史连续最长在线天数 + var DurationDays int64 + if err := l.svcCtx.DB.Model(user.DeviceOnlineRecord{}).Where("user_id = ?", u.Id).Select("duration_days").Order("duration_days desc").Limit(1).Scan(&DurationDays).Error; err != nil { + l.Logger.Error(err) + } + + //获取近七天在线情况 + var userOnlineRecord []user.DeviceOnlineRecord + if err := l.svcCtx.DB.Model(&userOnlineRecord).Where("user_id = ? and created_at >= ?", u.Id, time.Now().AddDate(0, 0, -7).Format(time.DateTime)).Order("created_at desc").Find(&userOnlineRecord).Error; err != nil { + l.Logger.Error(err) + } + + //获取当前连续在线天数 + var currentContinuousDays int64 + if len(userOnlineRecord) > 0 { + currentContinuousDays = userOnlineRecord[0].DurationDays + } else { + currentContinuousDays = 1 + } + + var dates []string + for i := 0; i < 7; i++ { + date := time.Now().AddDate(0, 0, -i).Format(time.DateOnly) + dates = append(dates, date) + } + + onlineDays := make(map[string]types.WeeklyStat) + for _, record := range userOnlineRecord { + //获取近七天在线情况 + onlineTime := record.OnlineTime.Format(time.DateOnly) + if weeklyStat, ok := onlineDays[onlineTime]; ok { + weeklyStat.Hours += float64(record.OnlineSeconds) + onlineDays[onlineTime] = weeklyStat + } else { + onlineDays[onlineTime] = types.WeeklyStat{ + Hours: float64(record.OnlineSeconds), + //根据日期获取周几 + DayName: record.OnlineTime.Weekday().String(), + } + } + } + + //补全不存在的日期 + for _, date := range dates { + if _, ok := onlineDays[date]; !ok { + onlineTime, _ := time.Parse(time.DateOnly, date) + onlineDays[date] = types.WeeklyStat{ + DayName: onlineTime.Weekday().String(), + } + } + } + + var keys []string + for key := range onlineDays { + keys = append(keys, key) + } + + //排序 + sort.Strings(keys) + + var weeklyStats []types.WeeklyStat + for index, key := range keys { + weeklyStat := onlineDays[key] + weeklyStat.Day = index + 1 + weeklyStat.Hours = weeklyStat.Hours / float64(3600) + weeklyStats = append(weeklyStats, weeklyStat) + } + + resp = &types.GetDeviceOnlineStatsResponse{ + WeeklyStats: weeklyStats, + ConnectionRecords: types.ConnectionRecords{ + CurrentContinuousDays: currentContinuousDays, + HistoryContinuousDays: DurationDays, + LongestSingleConnection: OnlineSeconds / 60, + }, + } + return +} diff --git a/internal/logic/public/user/resetUserSubscribeTokenLogic.go b/internal/logic/public/user/resetUserSubscribeTokenLogic.go index febcae7..56919f9 100644 --- a/internal/logic/public/user/resetUserSubscribeTokenLogic.go +++ b/internal/logic/public/user/resetUserSubscribeTokenLogic.go @@ -83,6 +83,9 @@ func (l *ResetUserSubscribeTokenLogic) ResetUserSubscribeToken(req *types.ResetU l.Errorw("ClearSubscribeCache failed", logger.Field("error", err.Error()), logger.Field("subscribeId", userSub.SubscribeId)) return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "ClearSubscribeCache failed: %v", err.Error()) } - + if err = l.svcCtx.NodeModel.ClearServerAllCache(l.ctx); err != nil { + l.Errorf("ClearServerAllCache error: %v", err.Error()) + return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "failed to clear server cache: %v", err.Error()) + } return nil } diff --git a/internal/logic/public/user/unbindDeviceLogic.go b/internal/logic/public/user/unbindDeviceLogic.go index 57218cc..c01b5e1 100644 --- a/internal/logic/public/user/unbindDeviceLogic.go +++ b/internal/logic/public/user/unbindDeviceLogic.go @@ -64,9 +64,23 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error { if err != nil { return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete device online record err: %v", err) } - sessionId := l.ctx.Value(constant.CtxKeySessionID) - sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId) - l.svcCtx.Redis.Del(l.ctx, sessionIdCacheKey) + var count int64 + err = tx.Model(user.AuthMethods{}).Where("user_id = ?", deleteDevice.UserId).Count(&count).Error + if err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count user auth methods err: %v", err) + } + + if count < 1 { + _ = tx.Where("id = ?", deleteDevice.UserId).Delete(&user.User{}).Error + } + + //remove device cache + deviceCacheKey := fmt.Sprintf("%v:%v", config.DeviceCacheKeyKey, deleteDevice.Identifier) + if sessionId, err := l.svcCtx.Redis.Get(l.ctx, deviceCacheKey).Result(); err == nil && sessionId != "" { + _ = l.svcCtx.Redis.Del(l.ctx, deviceCacheKey).Err() + sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId) + _ = l.svcCtx.Redis.Del(l.ctx, sessionIdCacheKey).Err() + } return nil }) } diff --git a/internal/logic/public/user/ws/deviceWsConnectLogic.go b/internal/logic/public/user/ws/deviceWsConnectLogic.go new file mode 100644 index 0000000..5024a7f --- /dev/null +++ b/internal/logic/public/user/ws/deviceWsConnectLogic.go @@ -0,0 +1,87 @@ +package ws + +import ( + "context" + sysErr "errors" + "time" + + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/logger" +) + +type DeviceWsConnectLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Webosocket Device Connect +func NewDeviceWsConnectLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeviceWsConnectLogic { + return &DeviceWsConnectLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *DeviceWsConnectLogic) DeviceWsConnect(c *gin.Context) error { + + value := l.ctx.Value(constant.CtxKeyIdentifier) + if value == nil || value.(string) == "" { + value, _ = c.GetQuery("identifier") + if value == nil || value.(string) == "" { + l.Errorf("DeviceWsConnectLogic DeviceWsConnect identifier is empty") + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "identifier is empty") + } + } + identifier := value.(string) + _, err := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, identifier) + if err != nil && !sysErr.Is(err, gorm.ErrRecordNotFound) { + l.Errorf("DeviceWsConnectLogic DeviceWsConnect FindOneDeviceByIdentifier err: %v", err) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + + value = l.ctx.Value(constant.CtxKeyUser) + if value == nil { + l.Errorf("DeviceWsConnectLogic DeviceWsConnect value is nil") + return nil + } + userInfo := value.(*user.User) + if sysErr.Is(err, gorm.ErrRecordNotFound) { + device := user.Device{ + Identifier: identifier, + UserId: userInfo.Id, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Online: true, + Enabled: true, + } + err := l.svcCtx.UserModel.InsertDevice(l.ctx, &device) + if err != nil { + l.Errorf("DeviceWsConnectLogic DeviceWsConnect InsertDevice err: %v", err) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), err.Error()) + } + } + //默认在线设备1 + maxDevice := 3 + subscribe, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, userInfo.Id, 1, 2) + if err == nil { + for _, sub := range subscribe { + if time.Now().Before(sub.ExpireTime) { + deviceLimit := int(sub.Subscribe.DeviceLimit) + if deviceLimit > maxDevice { + maxDevice = deviceLimit + } + } + } + } + l.svcCtx.DeviceManager.AddDevice(c.Writer, c.Request, l.ctx.Value(constant.CtxKeySessionID).(string), userInfo.Id, identifier, maxDevice) + return nil +} diff --git a/internal/middleware/authMiddleware.go b/internal/middleware/authMiddleware.go index fbf3758..9007ae9 100644 --- a/internal/middleware/authMiddleware.go +++ b/internal/middleware/authMiddleware.go @@ -42,8 +42,11 @@ func AuthMiddleware(svc *svc.ServiceContext) func(c *gin.Context) { } loginType := "" - if claims["LoginType"] != nil { - loginType = claims["LoginType"].(string) + if claims["CtxLoginType"] != nil { + loginType = claims["CtxLoginType"].(string) + } + if claims["identifier"] != nil { + ctx = context.WithValue(ctx, constant.CtxKeyIdentifier, claims["identifier"].(string)) } // get user id from token userId := int64(claims["UserId"].(float64)) @@ -82,9 +85,10 @@ func AuthMiddleware(svc *svc.ServiceContext) func(c *gin.Context) { c.Abort() return } - ctx = context.WithValue(ctx, constant.LoginType, loginType) + ctx = context.WithValue(ctx, constant.CtxLoginType, loginType) ctx = context.WithValue(ctx, constant.CtxKeyUser, userInfo) ctx = context.WithValue(ctx, constant.CtxKeySessionID, sessionId) + c.Request = c.Request.WithContext(ctx) c.Next() } diff --git a/internal/middleware/deviceMiddleware.go b/internal/middleware/deviceMiddleware.go index b66ccb0..cd91a58 100644 --- a/internal/middleware/deviceMiddleware.go +++ b/internal/middleware/deviceMiddleware.go @@ -42,11 +42,11 @@ func DeviceMiddleware(srvCtx *svc.ServiceContext) func(c *gin.Context) { ctx := c.Request.Context() if ctx.Value(constant.CtxKeyUser) == nil && c.GetHeader("Login-Type") != "" { - ctx = context.WithValue(ctx, constant.LoginType, c.GetHeader("Login-Type")) + ctx = context.WithValue(ctx, constant.CtxLoginType, c.GetHeader("Login-Type")) c.Request = c.Request.WithContext(ctx) } - loginType, ok := ctx.Value(constant.LoginType).(string) + loginType, ok := ctx.Value(constant.CtxLoginType).(string) if !ok || loginType != "device" { c.Next() return diff --git a/internal/model/announcement/model.go b/internal/model/announcement/model.go index 973fc97..6d83262 100644 --- a/internal/model/announcement/model.go +++ b/internal/model/announcement/model.go @@ -27,6 +27,9 @@ type Filter struct { // GetAnnouncementListByPage get announcement list by page func (m *customAnnouncementModel) GetAnnouncementListByPage(ctx context.Context, page, size int, filter Filter) (int64, []*Announcement, error) { + if size == 0 { + size = 10 + } var list []*Announcement var total int64 err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error { diff --git a/internal/model/node/model.go b/internal/model/node/model.go index ddfa736..f5d9eb2 100644 --- a/internal/model/node/model.go +++ b/internal/model/node/model.go @@ -13,6 +13,7 @@ type customServerLogicModel interface { FilterServerList(ctx context.Context, params *FilterParams) (int64, []*Server, error) FilterNodeList(ctx context.Context, params *FilterNodeParams) (int64, []*Node, error) ClearNodeCache(ctx context.Context, params *FilterNodeParams) error + ClearServerAllCache(ctx context.Context) error } const ( @@ -171,6 +172,30 @@ func (m *customServerModel) ClearServerCache(ctx context.Context, serverId int64 return nil } +func (m *customServerModel) ClearServerAllCache(ctx context.Context) error { + var cursor uint64 + var keys []string + prefix := ServerUserListCacheKey + "*" + for { + scanKeys, newCursor, err := m.Cache.Scan(ctx, cursor, prefix, 999).Result() + if err != nil { + m.Logger.Error(ctx, fmt.Sprintf("ClearServerAllCache err:%v", err)) + break + } + m.Logger.Info(ctx, fmt.Sprintf("ClearServerAllCache query keys:%v", scanKeys)) + keys = append(keys, scanKeys...) + cursor = newCursor + if cursor == 0 { + break + } + } + if len(keys) > 0 { + m.Logger.Info(ctx, fmt.Sprintf("ClearServerAllCache keys:%v", keys)) + return m.Cache.Del(ctx, keys...).Err() + } + return nil +} + // InSet 支持多值 OR 查询 func InSet(field string, values []string) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { diff --git a/internal/model/node/server.go b/internal/model/node/server.go index 00e433e..8431a57 100644 --- a/internal/model/node/server.go +++ b/internal/model/node/server.go @@ -15,12 +15,16 @@ type Server struct { Country string `gorm:"type:varchar(128);not null;default:'';comment:Country"` City string `gorm:"type:varchar(128);not null;default:'';comment:City"` //Ratio float32 `gorm:"type:DECIMAL(4,2);not null;default:0;comment:Traffic Ratio"` - Address string `gorm:"type:varchar(100);not null;default:'';comment:Server Address"` - Sort int `gorm:"type:int;not null;default:0;comment:Sort"` - Protocols string `gorm:"type:text;default:null;comment:Protocol"` - LastReportedAt *time.Time `gorm:"comment:Last Reported Time"` - CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` - UpdatedAt time.Time `gorm:"comment:Update Time"` + Address string `gorm:"type:varchar(100);not null;default:'';comment:Server Address"` + Sort int `gorm:"type:int;not null;default:0;comment:Sort"` + Protocols string `gorm:"type:text;default:null;comment:Protocol"` + LastReportedAt *time.Time `gorm:"comment:Last Reported Time"` + Longitude string `gorm:"type:varchar(50);not null;default:'0.0';comment:Longitude"` + Latitude string `gorm:"type:varchar(50);not null;default:'0.0';comment:Latitude"` + LongitudeCenter string `gorm:"type:varchar(50);not null;default:'0.0';comment:Center Longitude"` + LatitudeCenter string `gorm:"type:varchar(50);not null;default:'0.0';comment:Center Latitude"` + CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` + UpdatedAt time.Time `gorm:"comment:Update Time"` } func (*Server) TableName() string { diff --git a/internal/model/redemption/default.go b/internal/model/redemption/default.go new file mode 100644 index 0000000..3d8328b --- /dev/null +++ b/internal/model/redemption/default.go @@ -0,0 +1,279 @@ +package redemption + +import ( + "context" + "errors" + "fmt" + + "github.com/perfect-panel/server/pkg/cache" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +var _ RedemptionCodeModel = (*customRedemptionCodeModel)(nil) +var _ RedemptionRecordModel = (*customRedemptionRecordModel)(nil) + +var ( + cacheRedemptionCodeIdPrefix = "cache:redemption_code:id:" + cacheRedemptionCodeCodePrefix = "cache:redemption_code:code:" + cacheRedemptionRecordIdPrefix = "cache:redemption_record:id:" +) + +type ( + RedemptionCodeModel interface { + Insert(ctx context.Context, data *RedemptionCode) error + FindOne(ctx context.Context, id int64) (*RedemptionCode, error) + FindOneByCode(ctx context.Context, code string) (*RedemptionCode, error) + Update(ctx context.Context, data *RedemptionCode) error + Delete(ctx context.Context, id int64) error + Transaction(ctx context.Context, fn func(db *gorm.DB) error) error + customRedemptionCodeLogicModel + } + + RedemptionRecordModel interface { + Insert(ctx context.Context, data *RedemptionRecord) error + FindOne(ctx context.Context, id int64) (*RedemptionRecord, error) + Update(ctx context.Context, data *RedemptionRecord) error + Delete(ctx context.Context, id int64) error + customRedemptionRecordLogicModel + } + + customRedemptionCodeLogicModel interface { + QueryRedemptionCodeListByPage(ctx context.Context, page, size int, subscribePlan int64, unitTime string, code string) (total int64, list []*RedemptionCode, err error) + BatchDelete(ctx context.Context, ids []int64) error + IncrementUsedCount(ctx context.Context, id int64) error + } + + customRedemptionRecordLogicModel interface { + QueryRedemptionRecordListByPage(ctx context.Context, page, size int, userId int64, codeId int64) (total int64, list []*RedemptionRecord, err error) + FindByUserId(ctx context.Context, userId int64) ([]*RedemptionRecord, error) + FindByCodeId(ctx context.Context, codeId int64) ([]*RedemptionRecord, error) + } + + customRedemptionCodeModel struct { + *defaultRedemptionCodeModel + } + defaultRedemptionCodeModel struct { + cache.CachedConn + table string + } + + customRedemptionRecordModel struct { + *defaultRedemptionRecordModel + } + defaultRedemptionRecordModel struct { + cache.CachedConn + table string + } +) + +func newRedemptionCodeModel(db *gorm.DB, c *redis.Client) *defaultRedemptionCodeModel { + return &defaultRedemptionCodeModel{ + CachedConn: cache.NewConn(db, c), + table: "`redemption_code`", + } +} + +func newRedemptionRecordModel(db *gorm.DB, c *redis.Client) *defaultRedemptionRecordModel { + return &defaultRedemptionRecordModel{ + CachedConn: cache.NewConn(db, c), + table: "`redemption_record`", + } +} + +// RedemptionCode cache methods +func (m *defaultRedemptionCodeModel) getCacheKeys(data *RedemptionCode) []string { + if data == nil { + return []string{} + } + codeIdKey := fmt.Sprintf("%s%v", cacheRedemptionCodeIdPrefix, data.Id) + codeCodeKey := fmt.Sprintf("%s%v", cacheRedemptionCodeCodePrefix, data.Code) + cacheKeys := []string{ + codeIdKey, + codeCodeKey, + } + return cacheKeys +} + +func (m *defaultRedemptionCodeModel) Insert(ctx context.Context, data *RedemptionCode) error { + err := m.ExecCtx(ctx, func(conn *gorm.DB) error { + return conn.Create(data).Error + }, m.getCacheKeys(data)...) + return err +} + +func (m *defaultRedemptionCodeModel) FindOne(ctx context.Context, id int64) (*RedemptionCode, error) { + codeIdKey := fmt.Sprintf("%s%v", cacheRedemptionCodeIdPrefix, id) + var resp RedemptionCode + err := m.QueryCtx(ctx, &resp, codeIdKey, func(conn *gorm.DB, v interface{}) error { + return conn.Model(&RedemptionCode{}).Where("`id` = ?", id).First(&resp).Error + }) + switch { + case err == nil: + return &resp, nil + default: + return nil, err + } +} + +func (m *defaultRedemptionCodeModel) FindOneByCode(ctx context.Context, code string) (*RedemptionCode, error) { + codeCodeKey := fmt.Sprintf("%s%v", cacheRedemptionCodeCodePrefix, code) + var resp RedemptionCode + err := m.QueryCtx(ctx, &resp, codeCodeKey, func(conn *gorm.DB, v interface{}) error { + return conn.Model(&RedemptionCode{}).Where("`code` = ?", code).First(&resp).Error + }) + switch { + case err == nil: + return &resp, nil + default: + return nil, err + } +} + +func (m *defaultRedemptionCodeModel) Update(ctx context.Context, data *RedemptionCode) error { + old, err := m.FindOne(ctx, data.Id) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + err = m.ExecCtx(ctx, func(conn *gorm.DB) error { + db := conn + return db.Save(data).Error + }, m.getCacheKeys(old)...) + return err +} + +func (m *defaultRedemptionCodeModel) Delete(ctx context.Context, id int64) error { + data, err := m.FindOne(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + err = m.ExecCtx(ctx, func(conn *gorm.DB) error { + db := conn + return db.Delete(&RedemptionCode{}, id).Error + }, m.getCacheKeys(data)...) + return err +} + +func (m *defaultRedemptionCodeModel) Transaction(ctx context.Context, fn func(db *gorm.DB) error) error { + return m.TransactCtx(ctx, fn) +} + +// RedemptionCode custom logic methods +func (m *customRedemptionCodeModel) QueryRedemptionCodeListByPage(ctx context.Context, page, size int, subscribePlan int64, unitTime string, code string) (total int64, list []*RedemptionCode, err error) { + err = m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error { + db := conn.Model(&RedemptionCode{}) + if subscribePlan != 0 { + db = db.Where("subscribe_plan = ?", subscribePlan) + } + if unitTime != "" { + db = db.Where("unit_time = ?", unitTime) + } + if code != "" { + db = db.Where("code like ?", "%"+code+"%") + } + return db.Count(&total).Limit(size).Offset((page - 1) * size).Order("created_at DESC").Find(v).Error + }) + return total, list, err +} + +func (m *customRedemptionCodeModel) BatchDelete(ctx context.Context, ids []int64) error { + var err error + for _, id := range ids { + if err = m.Delete(ctx, id); err != nil { + return err + } + } + return nil +} + +func (m *customRedemptionCodeModel) IncrementUsedCount(ctx context.Context, id int64) error { + data, err := m.FindOne(ctx, id) + if err != nil { + return err + } + data.UsedCount++ + return m.Update(ctx, data) +} + +// RedemptionRecord cache methods +func (m *defaultRedemptionRecordModel) getCacheKeys(data *RedemptionRecord) []string { + if data == nil { + return []string{} + } + recordIdKey := fmt.Sprintf("%s%v", cacheRedemptionRecordIdPrefix, data.Id) + cacheKeys := []string{ + recordIdKey, + } + return cacheKeys +} + +func (m *defaultRedemptionRecordModel) Insert(ctx context.Context, data *RedemptionRecord) error { + err := m.ExecCtx(ctx, func(conn *gorm.DB) error { + return conn.Create(data).Error + }, m.getCacheKeys(data)...) + return err +} + +func (m *defaultRedemptionRecordModel) FindOne(ctx context.Context, id int64) (*RedemptionRecord, error) { + recordIdKey := fmt.Sprintf("%s%v", cacheRedemptionRecordIdPrefix, id) + var resp RedemptionRecord + err := m.QueryCtx(ctx, &resp, recordIdKey, func(conn *gorm.DB, v interface{}) error { + return conn.Model(&RedemptionRecord{}).Where("`id` = ?", id).First(&resp).Error + }) + switch { + case err == nil: + return &resp, nil + default: + return nil, err + } +} + +func (m *defaultRedemptionRecordModel) Update(ctx context.Context, data *RedemptionRecord) error { + err := m.ExecCtx(ctx, func(conn *gorm.DB) error { + db := conn + return db.Save(data).Error + }, m.getCacheKeys(data)...) + return err +} + +func (m *defaultRedemptionRecordModel) Delete(ctx context.Context, id int64) error { + err := m.ExecCtx(ctx, func(conn *gorm.DB) error { + db := conn + return db.Delete(&RedemptionRecord{}, id).Error + }, m.getCacheKeys(nil)...) + return err +} + +// RedemptionRecord custom logic methods +func (m *customRedemptionRecordModel) QueryRedemptionRecordListByPage(ctx context.Context, page, size int, userId int64, codeId int64) (total int64, list []*RedemptionRecord, err error) { + err = m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error { + db := conn.Model(&RedemptionRecord{}) + if userId != 0 { + db = db.Where("user_id = ?", userId) + } + if codeId != 0 { + db = db.Where("redemption_code_id = ?", codeId) + } + return db.Count(&total).Limit(size).Offset((page - 1) * size).Order("created_at DESC").Find(v).Error + }) + return total, list, err +} + +func (m *customRedemptionRecordModel) FindByUserId(ctx context.Context, userId int64) ([]*RedemptionRecord, error) { + var list []*RedemptionRecord + err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error { + return conn.Model(&RedemptionRecord{}).Where("user_id = ?", userId).Order("created_at DESC").Find(v).Error + }) + return list, err +} + +func (m *customRedemptionRecordModel) FindByCodeId(ctx context.Context, codeId int64) ([]*RedemptionRecord, error) { + var list []*RedemptionRecord + err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error { + return conn.Model(&RedemptionRecord{}).Where("redemption_code_id = ?", codeId).Order("created_at DESC").Find(v).Error + }) + return list, err +} diff --git a/internal/model/redemption/model.go b/internal/model/redemption/model.go new file mode 100644 index 0000000..ef7c6fe --- /dev/null +++ b/internal/model/redemption/model.go @@ -0,0 +1,20 @@ +package redemption + +import ( + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +// NewRedemptionCodeModel returns a model for the redemption_code table. +func NewRedemptionCodeModel(conn *gorm.DB, c *redis.Client) RedemptionCodeModel { + return &customRedemptionCodeModel{ + defaultRedemptionCodeModel: newRedemptionCodeModel(conn, c), + } +} + +// NewRedemptionRecordModel returns a model for the redemption_record table. +func NewRedemptionRecordModel(conn *gorm.DB, c *redis.Client) RedemptionRecordModel { + return &customRedemptionRecordModel{ + defaultRedemptionRecordModel: newRedemptionRecordModel(conn, c), + } +} diff --git a/internal/model/redemption/redemption.go b/internal/model/redemption/redemption.go new file mode 100644 index 0000000..9c2ca03 --- /dev/null +++ b/internal/model/redemption/redemption.go @@ -0,0 +1,40 @@ +package redemption + +import ( + "time" + + "gorm.io/gorm" +) + +type RedemptionCode struct { + Id int64 `gorm:"primaryKey"` + Code string `gorm:"type:varchar(255);not null;unique;comment:Redemption Code"` + TotalCount int64 `gorm:"type:int;not null;default:0;comment:Total Redemption Count"` + UsedCount int64 `gorm:"type:int;not null;default:0;comment:Used Redemption Count"` + SubscribePlan int64 `gorm:"type:bigint;not null;default:0;comment:Subscribe Plan"` + UnitTime string `gorm:"type:varchar(50);not null;default:'month';comment:Unit Time: day, month, quarter, half_year, year"` + Quantity int64 `gorm:"type:int;not null;default:1;comment:Quantity"` + Status int64 `gorm:"type:tinyint;not null;default:1;comment:Status: 1=enabled, 0=disabled"` + CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` + UpdatedAt time.Time `gorm:"comment:Update Time"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:Delete Time"` +} + +type RedemptionRecord struct { + Id int64 `gorm:"primaryKey"` + RedemptionCodeId int64 `gorm:"type:bigint;not null;default:0;comment:Redemption Code Id;index"` + UserId int64 `gorm:"type:bigint;not null;default:0;comment:User Id;index"` + SubscribeId int64 `gorm:"type:bigint;not null;default:0;comment:Subscribe Id"` + UnitTime string `gorm:"type:varchar(50);not null;default:'month';comment:Unit Time"` + Quantity int64 `gorm:"type:int;not null;default:1;comment:Quantity"` + RedeemedAt time.Time `gorm:"<-:create;comment:Redeemed Time"` + CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` +} + +func (RedemptionCode) TableName() string { + return "redemption_code" +} + +func (RedemptionRecord) TableName() string { + return "redemption_record" +} diff --git a/internal/model/user/model.go b/internal/model/user/model.go index 65b3589..e0c2a29 100644 --- a/internal/model/user/model.go +++ b/internal/model/user/model.go @@ -153,7 +153,7 @@ func (m *customUserModel) QueryPageList(ctx context.Context, page, size int, fil conn = conn.Unscoped() } } - return conn.Model(&User{}).Group("user.id").Count(&total).Limit(size).Offset((page - 1) * size).Preload("UserDevices").Preload("AuthMethods").Find(&list).Error + return conn.Model(&User{}).Group("user.id").Count(&total).Limit(size).Offset((page-1)*size).Preload("UserDevices").Preload("AuthMethods", func(db *gorm.DB) *gorm.DB { return db.Order("user_auth_methods.auth_type desc") }).Find(&list).Error }) return list, total, err } @@ -230,7 +230,7 @@ func (m *customUserModel) QueryResisterUserTotal(ctx context.Context) (int64, er func (m *customUserModel) QueryAdminUsers(ctx context.Context) ([]*User, error) { var data []*User err := m.QueryNoCacheCtx(ctx, &data, func(conn *gorm.DB, v interface{}) error { - return conn.Model(&User{}).Preload("AuthMethods").Where("is_admin = ?", true).Find(&data).Error + return conn.Model(&User{}).Preload("AuthMethods", func(db *gorm.DB) *gorm.DB { return db.Order("user_auth_methods.auth_type desc") }).Where("is_admin = ?", true).Find(&data).Error }) return data, err } diff --git a/internal/svc/devce.go b/internal/svc/devce.go index 2d1fd23..1a1556c 100644 --- a/internal/svc/devce.go +++ b/internal/svc/devce.go @@ -51,7 +51,7 @@ func NewDeviceManager(srv *ServiceContext) *device.DeviceManager { //获取设备昨日在线记录 var onlineRecord user.DeviceOnlineRecord - if err := srv.DB.Model(&onlineRecord).Where("user_id = ? and create_at >= ? and create_at < ?", userID, startTime, endTime).First(&onlineRecord).Error; err != nil { + if err := srv.DB.Model(&onlineRecord).Where("user_id = ? and created_at >= ? and created_at < ?", userID, startTime, endTime).First(&onlineRecord).Error; err != nil { //昨日未在线,连续在线天数为1 deviceOnlineRecord.DurationDays = 1 } else { diff --git a/internal/svc/serviceContext.go b/internal/svc/serviceContext.go index aa79ccc..5650b4b 100644 --- a/internal/svc/serviceContext.go +++ b/internal/svc/serviceContext.go @@ -5,6 +5,7 @@ import ( "github.com/perfect-panel/server/internal/model/client" "github.com/perfect-panel/server/internal/model/node" + "github.com/perfect-panel/server/internal/model/redemption" "github.com/perfect-panel/server/pkg/device" "github.com/perfect-panel/server/internal/config" @@ -49,13 +50,15 @@ type ServiceContext struct { ClientModel client.Model TicketModel ticket.Model //ServerModel server.Model - SystemModel system.Model - CouponModel coupon.Model - PaymentModel payment.Model - DocumentModel document.Model - SubscribeModel subscribe.Model - TrafficLogModel traffic.Model - AnnouncementModel announcement.Model + SystemModel system.Model + CouponModel coupon.Model + RedemptionCodeModel redemption.RedemptionCodeModel + RedemptionRecordModel redemption.RedemptionRecordModel + PaymentModel payment.Model + DocumentModel document.Model + SubscribeModel subscribe.Model + TrafficLogModel traffic.Model + AnnouncementModel announcement.Model Restart func() error TelegramBot *tgbotapi.BotAPI @@ -110,13 +113,15 @@ func NewServiceContext(c config.Config) *ServiceContext { ClientModel: client.NewSubscribeApplicationModel(db), TicketModel: ticket.NewModel(db, rds), //ServerModel: server.NewModel(db, rds), - SystemModel: system.NewModel(db, rds), - CouponModel: coupon.NewModel(db, rds), - PaymentModel: payment.NewModel(db, rds), - DocumentModel: document.NewModel(db, rds), - SubscribeModel: subscribe.NewModel(db, rds), - TrafficLogModel: traffic.NewModel(db), - AnnouncementModel: announcement.NewModel(db, rds), + SystemModel: system.NewModel(db, rds), + CouponModel: coupon.NewModel(db, rds), + RedemptionCodeModel: redemption.NewRedemptionCodeModel(db, rds), + RedemptionRecordModel: redemption.NewRedemptionRecordModel(db, rds), + PaymentModel: payment.NewModel(db, rds), + DocumentModel: document.NewModel(db, rds), + SubscribeModel: subscribe.NewModel(db, rds), + TrafficLogModel: traffic.NewModel(db), + AnnouncementModel: announcement.NewModel(db, rds), } srv.DeviceManager = NewDeviceManager(srv) return srv diff --git a/internal/types/types.go b/internal/types/types.go index 97042bd..5369547 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -146,6 +146,10 @@ type BatchDeleteDocumentRequest struct { Ids []int64 `json:"ids" validate:"required"` } +type BatchDeleteRedemptionCodeRequest struct { + Ids []int64 `json:"ids" validate:"required"` +} + type BatchDeleteSubscribeGroupRequest struct { Ids []int64 `json:"ids" validate:"required"` } @@ -244,6 +248,12 @@ type CommissionWithdrawRequest struct { Content string `json:"content"` } +type ConnectionRecords struct { + CurrentContinuousDays int64 `json:"current_continuous_days"` + HistoryContinuousDays int64 `json:"history_continuous_days"` + LongestSingleConnection int64 `json:"longest_single_connection"` +} + type Coupon struct { Id int64 `json:"id"` Name string `json:"name"` @@ -361,6 +371,14 @@ type CreateQuotaTaskRequest struct { GiftValue uint64 `json:"gift_value"` } +type CreateRedemptionCodeRequest struct { + TotalCount int64 `json:"total_count" validate:"required"` + SubscribePlan int64 `json:"subscribe_plan" validate:"required"` + UnitTime string `json:"unit_time" validate:"required,oneof=day month quarter half_year year"` + Quantity int64 `json:"quantity" validate:"required"` + BatchCount int64 `json:"batch_count" validate:"required,min=1"` +} + type CreateServerRequest struct { Name string `json:"name"` Country string `json:"country,omitempty"` @@ -495,6 +513,10 @@ type DeletePaymentMethodRequest struct { Id int64 `json:"id" validate:"required"` } +type DeleteRedemptionCodeRequest struct { + Id int64 `json:"id" validate:"required"` +} + type DeleteServerRequest struct { Id int64 `json:"id"` } @@ -828,6 +850,11 @@ type GetDeviceListResponse struct { Total int64 `json:"total"` } +type GetDeviceOnlineStatsResponse struct { + WeeklyStats []WeeklyStat `json:"weekly_stats"` + ConnectionRecords ConnectionRecords `json:"connection_records"` +} + type GetDocumentDetailRequest struct { Id int64 `json:"id" validate:"required"` } @@ -923,6 +950,31 @@ type GetPreSendEmailCountResponse struct { Count int64 `json:"count"` } +type GetRedemptionCodeListRequest struct { + Page int64 `form:"page" validate:"required"` + Size int64 `form:"size" validate:"required"` + SubscribePlan int64 `form:"subscribe_plan,omitempty"` + UnitTime string `form:"unit_time,omitempty"` + Code string `form:"code,omitempty"` +} + +type GetRedemptionCodeListResponse struct { + Total int64 `json:"total"` + List []RedemptionCode `json:"list"` +} + +type GetRedemptionRecordListRequest struct { + Page int64 `form:"page" validate:"required"` + Size int64 `form:"size" validate:"required"` + UserId int64 `form:"user_id,omitempty"` + CodeId int64 `form:"code_id,omitempty"` +} + +type GetRedemptionRecordListResponse struct { + Total int64 `json:"total"` + List []RedemptionRecord `json:"list"` +} + type GetServerConfigRequest struct { ServerCommon } @@ -1559,7 +1611,7 @@ type PurchaseOrderResponse struct { type QueryAnnouncementRequest struct { Page int `form:"page"` - Size int `form:"size"` + Size int `form:"size,default=15"` Pinned *bool `form:"pinned"` Popup *bool `form:"popup"` } @@ -1768,6 +1820,38 @@ type RechargeOrderResponse struct { OrderNo string `json:"order_no"` } +type RedeemCodeRequest struct { + Code string `json:"code" validate:"required"` +} + +type RedeemCodeResponse struct { + Message string `json:"message"` +} + +type RedemptionCode struct { + Id int64 `json:"id"` + Code string `json:"code"` + TotalCount int64 `json:"total_count"` + UsedCount int64 `json:"used_count"` + SubscribePlan int64 `json:"subscribe_plan"` + UnitTime string `json:"unit_time"` + Quantity int64 `json:"quantity"` + Status int64 `json:"status"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type RedemptionRecord struct { + Id int64 `json:"id"` + RedemptionCodeId int64 `json:"redemption_code_id"` + UserId int64 `json:"user_id"` + SubscribeId int64 `json:"subscribe_id"` + UnitTime string `json:"unit_time"` + Quantity int64 `json:"quantity"` + RedeemedAt int64 `json:"redeemed_at"` + CreatedAt int64 `json:"created_at"` +} + type RegisterConfig struct { StopRegister bool `json:"stop_register"` EnableTrial bool `json:"enable_trial"` @@ -1777,6 +1861,7 @@ type RegisterConfig struct { EnableIpRegisterLimit bool `json:"enable_ip_register_limit"` IpRegisterLimit int64 `json:"ip_register_limit"` IpRegisterLimitDuration int64 `json:"ip_register_limit_duration"` + DeviceLimit int64 `json:"device_limit"` } type RegisterLog struct { @@ -2235,6 +2320,11 @@ type ToggleNodeStatusRequest struct { Enable *bool `json:"enable"` } +type ToggleRedemptionCodeStatusRequest struct { + Id int64 `json:"id" validate:"required"` + Status int64 `json:"status" validate:"oneof=0 1"` +} + type ToggleUserSubscribeStatusRequest struct { UserSubscribeId int64 `json:"user_subscribe_id"` } @@ -2405,6 +2495,15 @@ type UpdatePaymentMethodRequest struct { Enable *bool `json:"enable" validate:"required"` } +type UpdateRedemptionCodeRequest struct { + Id int64 `json:"id" validate:"required"` + TotalCount int64 `json:"total_count,omitempty"` + SubscribePlan int64 `json:"subscribe_plan,omitempty"` + UnitTime string `json:"unit_time,omitempty" validate:"omitempty,oneof=day month quarter half_year year"` + Quantity int64 `json:"quantity,omitempty"` + Status int64 `json:"status,omitempty" validate:"omitempty,oneof=0 1"` +} + type UpdateServerRequest struct { Id int64 `json:"id"` Name string `json:"name"` @@ -2552,6 +2651,7 @@ type User struct { CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` DeletedAt int64 `json:"deleted_at,omitempty"` + IsDel bool `json:"is_del,omitempty"` } type UserAffiliate struct { @@ -2693,16 +2793,21 @@ type UserSubscribeLog struct { } type UserSubscribeNodeInfo struct { - Id int64 `json:"id"` - Name string `json:"name"` - Uuid string `json:"uuid"` - Protocol string `json:"protocol"` - Port uint16 `json:"port"` - Address string `json:"address"` - Tags []string `json:"tags"` - Country string `json:"country"` - City string `json:"city"` - CreatedAt int64 `json:"created_at"` + Id int64 `json:"id"` + Name string `json:"name"` + Uuid string `json:"uuid"` + Protocol string `json:"protocol"` + Protocols string `json:"protocols"` + Port uint16 `json:"port"` + Address string `json:"address"` + Tags []string `json:"tags"` + Country string `json:"country"` + City string `json:"city"` + Longitude string `json:"longitude"` + Latitude string `json:"latitude"` + LatitudeCenter string `json:"latitude_center"` + LongitudeCenter string `json:"longitude_center"` + CreatedAt int64 `json:"created_at"` } type UserSubscribeTrafficLog struct { @@ -2793,6 +2898,12 @@ type VmessProtocol struct { Transport string `json:"transport"` } +type WeeklyStat struct { + Day int `json:"day"` + DayName string `json:"day_name"` + Hours float64 `json:"hours"` +} + type WithdrawalLog struct { Id int64 `json:"id"` UserId int64 `json:"user_id"` diff --git a/pkg/constant/context.go b/pkg/constant/context.go index 45c7f86..b137584 100644 --- a/pkg/constant/context.go +++ b/pkg/constant/context.go @@ -8,5 +8,6 @@ const ( CtxKeyRequestHost CtxKey = "requestHost" CtxKeyPlatform CtxKey = "platform" CtxKeyPayment CtxKey = "payment" - LoginType CtxKey = "loginType" + CtxLoginType CtxKey = "loginType" + CtxKeyIdentifier CtxKey = "identifier" ) diff --git a/pkg/ip/center.go b/pkg/ip/center.go new file mode 100644 index 0000000..936008b --- /dev/null +++ b/pkg/ip/center.go @@ -0,0 +1,2524 @@ +package ip + +import "strings" + +type location struct { + Name string + Acronym string + Child []*location + Latitude string `json:"latitude"` + Longitude string `json:"longitude"` +} + +var ( + center = []location{ + { + Name: "Afghanistan", + Acronym: "AF", + Latitude: "34.5205", + Longitude: "69.1778", + Child: []*location{}, + }, + { + Name: "Albania", + Acronym: "AL", + Latitude: "41.3317", + Longitude: "19.8318", + Child: []*location{}, + }, + { + Name: "Algeria", + Acronym: "DZ", + Latitude: "36.7372", + Longitude: "3.0865", + Child: []*location{}, + }, + { + Name: "Andorra", + Acronym: "AD", + Latitude: "42.5462", + Longitude: "1.6016", + Child: []*location{}, + }, + { + Name: "Angola", + Acronym: "AO", + Latitude: "-8.8383", + Longitude: "13.2344", + Child: []*location{}, + }, + { + Name: "Antigua and Barbuda", + Acronym: "AG", + Latitude: "17.0608", + Longitude: "-61.7964", + Child: []*location{}, + }, + { + Name: "Argentina", + Acronym: "AR", + Latitude: "-34.6118", + Longitude: "-58.3960", + Child: []*location{}, + }, + { + Name: "Armenia", + Acronym: "AM", + Latitude: "40.1792", + Longitude: "44.4991", + Child: []*location{}, + }, + { + Name: "Australia", + Acronym: "AU", + Latitude: "-35.2809", + Longitude: "149.1300", + Child: []*location{ + { + Name: "New South Wales", + Acronym: "NSW", + Latitude: "-33.8688", + Longitude: "151.2093", + Child: []*location{}, + }, + { + Name: "Victoria", + Acronym: "VIC", + Latitude: "-37.8136", + Longitude: "144.9631", + Child: []*location{}, + }, + { + Name: "Queensland", + Acronym: "QLD", + Latitude: "-27.4698", + Longitude: "153.0251", + Child: []*location{}, + }, + { + Name: "Western Australia", + Acronym: "WA", + Latitude: "-31.9505", + Longitude: "115.8605", + Child: []*location{}, + }, + { + Name: "South Australia", + Acronym: "SA", + Latitude: "-34.9285", + Longitude: "138.6007", + Child: []*location{}, + }, + { + Name: "Tasmania", + Acronym: "TAS", + Latitude: "-42.8821", + Longitude: "147.3272", + Child: []*location{}, + }, + { + Name: "Northern Territory", + Acronym: "NT", + Latitude: "-12.4634", + Longitude: "130.8456", + Child: []*location{}, + }, + { + Name: "Australian Capital Territory", + Acronym: "ACT", + Latitude: "-35.2809", + Longitude: "149.1300", + Child: []*location{}, + }, + }, + }, + { + Name: "Austria", + Acronym: "AT", + Latitude: "48.2082", + Longitude: "16.3738", + Child: []*location{}, + }, + { + Name: "Azerbaijan", + Acronym: "AZ", + Latitude: "40.4093", + Longitude: "49.8671", + Child: []*location{}, + }, + { + Name: "Bahamas", + Acronym: "BS", + Latitude: "25.0480", + Longitude: "-77.3554", + Child: []*location{}, + }, + { + Name: "Bahrain", + Acronym: "BH", + Latitude: "26.0667", + Longitude: "50.5577", + Child: []*location{}, + }, + { + Name: "Bangladesh", + Acronym: "BD", + Latitude: "23.8103", + Longitude: "90.4125", + Child: []*location{}, + }, + { + Name: "Barbados", + Acronym: "BB", + Latitude: "13.1132", + Longitude: "-59.5988", + Child: []*location{}, + }, + { + Name: "Belarus", + Acronym: "BY", + Latitude: "53.9045", + Longitude: "27.5615", + Child: []*location{}, + }, + { + Name: "Belgium", + Acronym: "BE", + Latitude: "50.8503", + Longitude: "4.3517", + Child: []*location{}, + }, + { + Name: "Belize", + Acronym: "BZ", + Latitude: "17.2500", + Longitude: "-88.7667", + Child: []*location{}, + }, + { + Name: "Benin", + Acronym: "BJ", + Latitude: "6.3977", + Longitude: "2.4167", + Child: []*location{}, + }, + { + Name: "Bhutan", + Acronym: "BT", + Latitude: "27.4661", + Longitude: "89.6419", + Child: []*location{}, + }, + { + Name: "Bolivia", + Acronym: "BO", + Latitude: "-16.5000", + Longitude: "-68.1500", + Child: []*location{}, + }, + { + Name: "Bosnia and Herzegovina", + Acronym: "BA", + Latitude: "43.8563", + Longitude: "18.4131", + Child: []*location{}, + }, + { + Name: "Botswana", + Acronym: "BW", + Latitude: "-24.6570", + Longitude: "25.9089", + Child: []*location{}, + }, + { + Name: "Brazil", + Acronym: "BR", + Latitude: "-15.8267", + Longitude: "-47.9218", + Child: []*location{ + { + Name: "São Paulo", + Acronym: "SP", + Latitude: "-23.5505", + Longitude: "-46.6333", + Child: []*location{}, + }, + { + Name: "Rio de Janeiro", + Acronym: "RJ", + Latitude: "-22.9068", + Longitude: "-43.1729", + Child: []*location{}, + }, + { + Name: "Minas Gerais", + Acronym: "MG", + Latitude: "-19.9167", + Longitude: "-43.9345", + Child: []*location{}, + }, + { + Name: "Bahia", + Acronym: "BA", + Latitude: "-12.9714", + Longitude: "-38.5014", + Child: []*location{}, + }, + { + Name: "Rio Grande do Sul", + Acronym: "RS", + Latitude: "-30.0346", + Longitude: "-51.2177", + Child: []*location{}, + }, + { + Name: "Paraná", + Acronym: "PR", + Latitude: "-25.4284", + Longitude: "-49.2733", + Child: []*location{}, + }, + { + Name: "Pernambuco", + Acronym: "PE", + Latitude: "-8.0476", + Longitude: "-34.8770", + Child: []*location{}, + }, + { + Name: "Ceará", + Acronym: "CE", + Latitude: "-3.7172", + Longitude: "-38.5434", + Child: []*location{}, + }, + }, + }, + { + Name: "Brunei", + Acronym: "BN", + Latitude: "4.9031", + Longitude: "114.9398", + Child: []*location{}, + }, + { + Name: "Bulgaria", + Acronym: "BG", + Latitude: "42.6977", + Longitude: "23.3219", + Child: []*location{}, + }, + { + Name: "Burkina Faso", + Acronym: "BF", + Latitude: "12.3714", + Longitude: "-1.5197", + Child: []*location{}, + }, + { + Name: "Burundi", + Acronym: "BI", + Latitude: "-3.3842", + Longitude: "29.3611", + Child: []*location{}, + }, + { + Name: "Cabo Verde", + Acronym: "CV", + Latitude: "14.9331", + Longitude: "-23.5133", + Child: []*location{}, + }, + { + Name: "Cambodia", + Acronym: "KH", + Latitude: "11.5564", + Longitude: "104.9282", + Child: []*location{}, + }, + { + Name: "Cameroon", + Acronym: "CM", + Latitude: "3.8480", + Longitude: "11.5021", + Child: []*location{}, + }, + { + Name: "Canada", + Acronym: "CA", + Latitude: "45.4215", + Longitude: "-75.6972", + Child: []*location{ + { + Name: "Ontario", + Acronym: "ON", + Latitude: "51.2538", + Longitude: "-85.3232", + Child: []*location{}, + }, + { + Name: "Quebec", + Acronym: "QC", + Latitude: "52.7395", + Longitude: "-73.4980", + Child: []*location{}, + }, + { + Name: "British Columbia", + Acronym: "BC", + Latitude: "53.9333", + Longitude: "-125.7833", + Child: []*location{}, + }, + { + Name: "Alberta", + Acronym: "AB", + Latitude: "53.9333", + Longitude: "-116.5765", + Child: []*location{}, + }, + { + Name: "Manitoba", + Acronym: "MB", + Latitude: "49.8943", + Longitude: "-97.1385", + Child: []*location{}, + }, + { + Name: "Saskatchewan", + Acronym: "SK", + Latitude: "50.8485", + Longitude: "-106.4520", + Child: []*location{}, + }, + { + Name: "Nova Scotia", + Acronym: "NS", + Latitude: "44.6820", + Longitude: "-63.7443", + Child: []*location{}, + }, + { + Name: "New Brunswick", + Acronym: "NB", + Latitude: "46.4983", + Longitude: "-66.0633", + Child: []*location{}, + }, + { + Name: "Newfoundland and Labrador", + Acronym: "NL", + Latitude: "47.5615", + Longitude: "-52.7126", + Child: []*location{}, + }, + { + Name: "Prince Edward Island", + Acronym: "PE", + Latitude: "46.2500", + Longitude: "-63.0000", + Child: []*location{}, + }, + { + Name: "Northwest Territories", + Acronym: "NT", + Latitude: "62.4540", + Longitude: "-114.3718", + Child: []*location{}, + }, + { + Name: "Yukon", + Acronym: "YT", + Latitude: "64.2823", + Longitude: "-135.0000", + Child: []*location{}, + }, + { + Name: "Nunavut", + Acronym: "NU", + Latitude: "70.0000", + Longitude: "-95.0000", + Child: []*location{}, + }, + }, + }, + { + Name: "Central African Republic", + Acronym: "CF", + Latitude: "4.3947", + Longitude: "18.5582", + Child: []*location{}, + }, + { + Name: "Chad", + Acronym: "TD", + Latitude: "12.1348", + Longitude: "15.0557", + Child: []*location{}, + }, + { + Name: "Chile", + Acronym: "CL", + Latitude: "-33.4489", + Longitude: "-70.6693", + Child: []*location{}, + }, + { + Name: "China", + Acronym: "CN", + Latitude: "39.9042", + Longitude: "116.4074", + Child: []*location{ + { + Name: "Beijing", + Acronym: "BJ", + Latitude: "39.9042", + Longitude: "116.4074", + Child: []*location{}, + }, + { + Name: "Shanghai", + Acronym: "SH", + Latitude: "31.2304", + Longitude: "121.4737", + Child: []*location{}, + }, + { + Name: "Guangdong", + Acronym: "GD", + Latitude: "23.1291", + Longitude: "113.2644", + Child: []*location{}, + }, + { + Name: "Zhejiang", + Acronym: "ZJ", + Latitude: "30.2741", + Longitude: "120.1551", + Child: []*location{}, + }, + { + Name: "Jiangsu", + Acronym: "JS", + Latitude: "32.0617", + Longitude: "118.7778", + Child: []*location{}, + }, + { + Name: "Sichuan", + Acronym: "SC", + Latitude: "30.5728", + Longitude: "104.0668", + Child: []*location{}, + }, + { + Name: "Hubei", + Acronym: "HB", + Latitude: "30.5928", + Longitude: "114.3055", + Child: []*location{}, + }, + { + Name: "Hunan", + Acronym: "HN", + Latitude: "28.2282", + Longitude: "112.9388", + Child: []*location{}, + }, + { + Name: "Henan", + Acronym: "HA", + Latitude: "34.7466", + Longitude: "113.6254", + Child: []*location{}, + }, + { + Name: "Shandong", + Acronym: "SD", + Latitude: "36.6758", + Longitude: "117.0009", + Child: []*location{}, + }, + { + Name: "Hebei", + Acronym: "HE", + Latitude: "38.0428", + Longitude: "114.5149", + Child: []*location{}, + }, + { + Name: "Liaoning", + Acronym: "LN", + Latitude: "41.8057", + Longitude: "123.4315", + Child: []*location{}, + }, + { + Name: "Jilin", + Acronym: "JL", + Latitude: "43.8868", + Longitude: "125.3245", + Child: []*location{}, + }, + { + Name: "Heilongjiang", + Acronym: "HL", + Latitude: "45.7732", + Longitude: "126.6618", + Child: []*location{}, + }, + { + Name: "Shaanxi", + Acronym: "SN", + Latitude: "34.3416", + Longitude: "108.9398", + Child: []*location{}, + }, + { + Name: "Gansu", + Acronym: "GS", + Latitude: "36.0611", + Longitude: "103.8343", + Child: []*location{}, + }, + { + Name: "Qinghai", + Acronym: "QH", + Latitude: "36.6171", + Longitude: "101.7782", + Child: []*location{}, + }, + { + Name: "Xinjiang", + Acronym: "XJ", + Latitude: "43.7928", + Longitude: "87.6177", + Child: []*location{}, + }, + { + Name: "Tibet", + Acronym: "XZ", + Latitude: "29.6520", + Longitude: "91.1720", + Child: []*location{}, + }, + { + Name: "Guangxi", + Acronym: "GX", + Latitude: "22.8154", + Longitude: "108.3275", + Child: []*location{}, + }, + { + Name: "Inner Mongolia", + Acronym: "NM", + Latitude: "40.8414", + Longitude: "111.7519", + Child: []*location{}, + }, + { + Name: "Ningxia", + Acronym: "NX", + Latitude: "38.4680", + Longitude: "106.2731", + Child: []*location{}, + }, + { + Name: "Hainan", + Acronym: "HI", + Latitude: "19.2041", + Longitude: "110.1999", + Child: []*location{}, + }, + { + Name: "Chongqing", + Acronym: "CQ", + Latitude: "29.5630", + Longitude: "106.5516", + Child: []*location{}, + }, + { + Name: "Tianjin", + Acronym: "TJ", + Latitude: "39.0842", + Longitude: "117.2010", + Child: []*location{}, + }, + { + Name: "Hong Kong", + Acronym: "HK", + Latitude: "22.3193", + Longitude: "114.1694", + Child: []*location{}, + }, + { + Name: "Macao", + Acronym: "MO", + Latitude: "22.1987", + Longitude: "113.5439", + Child: []*location{}, + }, + }, + }, + { + Name: "Colombia", + Acronym: "CO", + Latitude: "4.7110", + Longitude: "-74.0721", + Child: []*location{}, + }, + { + Name: "Comoros", + Acronym: "KM", + Latitude: "-11.8750", + Longitude: "43.3722", + Child: []*location{}, + }, + { + Name: "Congo", + Acronym: "CG", + Latitude: "-4.2634", + Longitude: "15.2429", + Child: []*location{}, + }, + { + Name: "Congo, Democratic Republic of the", + Acronym: "CD", + Latitude: "-4.4419", + Longitude: "15.2663", + Child: []*location{}, + }, + { + Name: "Costa Rica", + Acronym: "CR", + Latitude: "9.9333", + Longitude: "-84.0833", + Child: []*location{}, + }, + { + Name: "Côte d'Ivoire", + Acronym: "CI", + Latitude: "5.3600", + Longitude: "-4.0083", + Child: []*location{}, + }, + { + Name: "Croatia", + Acronym: "HR", + Latitude: "45.8150", + Longitude: "15.9785", + Child: []*location{}, + }, + { + Name: "Cuba", + Acronym: "CU", + Latitude: "23.1136", + Longitude: "-82.3666", + Child: []*location{}, + }, + { + Name: "Cyprus", + Acronym: "CY", + Latitude: "35.1856", + Longitude: "33.3823", + Child: []*location{}, + }, + { + Name: "Czech Republic", + Acronym: "CZ", + Latitude: "50.0755", + Longitude: "14.4378", + Child: []*location{}, + }, + { + Name: "Denmark", + Acronym: "DK", + Latitude: "55.6761", + Longitude: "12.5683", + Child: []*location{}, + }, + { + Name: "Djibouti", + Acronym: "DJ", + Latitude: "11.8271", + Longitude: "42.5905", + Child: []*location{}, + }, + { + Name: "Dominica", + Acronym: "DM", + Latitude: "15.4150", + Longitude: "-61.3710", + Child: []*location{}, + }, + { + Name: "Dominican Republic", + Acronym: "DO", + Latitude: "18.4802", + Longitude: "-69.9381", + Child: []*location{}, + }, + { + Name: "East Timor", + Acronym: "TL", + Latitude: "-8.5586", + Longitude: "125.5736", + Child: []*location{}, + }, + { + Name: "Ecuador", + Acronym: "EC", + Latitude: "-0.2295", + Longitude: "-78.5243", + Child: []*location{}, + }, + { + Name: "Egypt", + Acronym: "EG", + Latitude: "30.0444", + Longitude: "31.2357", + Child: []*location{}, + }, + { + Name: "El Salvador", + Acronym: "SV", + Latitude: "13.7021", + Longitude: "-89.2076", + Child: []*location{}, + }, + { + Name: "Equatorial Guinea", + Acronym: "GQ", + Latitude: "3.7452", + Longitude: "8.7376", + Child: []*location{}, + }, + { + Name: "Eritrea", + Acronym: "ER", + Latitude: "15.3333", + Longitude: "38.9167", + Child: []*location{}, + }, + { + Name: "Estonia", + Acronym: "EE", + Latitude: "59.4370", + Longitude: "24.7536", + Child: []*location{}, + }, + { + Name: "Eswatini", + Acronym: "SZ", + Latitude: "-26.3167", + Longitude: "31.1333", + Child: []*location{}, + }, + { + Name: "Ethiopia", + Acronym: "ET", + Latitude: "9.1450", + Longitude: "40.4897", + Child: []*location{}, + }, + { + Name: "Fiji", + Acronym: "FJ", + Latitude: "-18.1248", + Longitude: "178.4501", + Child: []*location{}, + }, + { + Name: "Finland", + Acronym: "FI", + Latitude: "60.1695", + Longitude: "24.9354", + Child: []*location{}, + }, + { + Name: "France", + Acronym: "FR", + Latitude: "48.8566", + Longitude: "2.3522", + Child: []*location{ + { + Name: "Provence", + Acronym: "PAC", + Latitude: "43.9352", + Longitude: "6.0679", + Child: []*location{}, + }, + { + Name: "Île-de-France", + Acronym: "IDF", + Latitude: "48.8566", + Longitude: "2.3522", + Child: []*location{}, + }, + { + Name: "Nouvelle-Aquitaine", + Acronym: "NAQ", + Latitude: "44.8378", + Longitude: "-0.5792", + Child: []*location{}, + }, + { + Name: "Auvergne", + Acronym: "ARA", + Latitude: "45.7772", + Longitude: "3.0870", + Child: []*location{}, + }, + { + Name: "Occitanie", + Acronym: "OCC", + Latitude: "43.6047", + Longitude: "1.4442", + Child: []*location{}, + }, + { + Name: "Rhône", + Acronym: "ARA", + Latitude: "45.7640", + Longitude: "4.8357", + Child: []*location{}, + }, + { + Name: "Andalusia", + Acronym: "AND", + Latitude: "37.3891", + Longitude: "-5.9845", + Child: []*location{}, + }, + { + Name: "Catalonia", + Acronym: "CAT", + Latitude: "41.3851", + Longitude: "2.1734", + Child: []*location{}, + }, + { + Name: "Madrid", + Acronym: "MAD", + Latitude: "40.4168", + Longitude: "-3.7038", + Child: []*location{}, + }, + { + Name: "Valencia", + Acronym: "VAL", + Latitude: "39.4699", + Longitude: "-0.3763", + Child: []*location{}, + }, + { + Name: "Galicia", + Acronym: "GAL", + Latitude: "42.6020", + Longitude: "-8.7076", + Child: []*location{}, + }, + }, + }, + { + Name: "Gabon", + Acronym: "GA", + Latitude: "0.4162", + Longitude: "9.4476", + Child: []*location{}, + }, + { + Name: "Gambia", + Acronym: "GM", + Latitude: "13.4531", + Longitude: "-16.5775", + Child: []*location{}, + }, + { + Name: "Georgia", + Acronym: "GE", + Latitude: "41.7151", + Longitude: "44.8278", + Child: []*location{}, + }, + { + Name: "Germany", + Acronym: "DE", + Latitude: "52.5200", + Longitude: "13.4050", + Child: []*location{ + { + Name: "Bavaria", + Acronym: "BY", + Latitude: "48.1351", + Longitude: "11.5820", + Child: []*location{}, + }, + { + Name: "North Rhine-Westphalia", + Acronym: "NW", + Latitude: "51.4332", + Longitude: "7.6616", + Child: []*location{}, + }, + { + Name: "Baden-Württemberg", + Acronym: "BW", + Latitude: "48.7758", + Longitude: "9.1829", + Child: []*location{}, + }, + { + Name: "Lower Saxony", + Acronym: "NI", + Latitude: "52.3759", + Longitude: "9.7320", + Child: []*location{}, + }, + { + Name: "Hesse", + Acronym: "HE", + Latitude: "50.1109", + Longitude: "8.6821", + Child: []*location{}, + }, + { + Name: "Saxony", + Acronym: "SN", + Latitude: "51.0834", + Longitude: "13.8121", + Child: []*location{}, + }, + { + Name: "Free State of Bavaria", + Acronym: "BY", + Latitude: "48.1351", + Longitude: "11.5820", + Child: []*location{}, + }, + }, + }, + { + Name: "Ghana", + Acronym: "GH", + Latitude: "5.6037", + Longitude: "-0.1870", + Child: []*location{}, + }, + { + Name: "Greece", + Acronym: "GR", + Latitude: "37.9838", + Longitude: "23.7275", + Child: []*location{}, + }, + { + Name: "Grenada", + Acronym: "GD", + Latitude: "12.0528", + Longitude: "-61.7525", + Child: []*location{}, + }, + { + Name: "Guatemala", + Acronym: "GT", + Latitude: "14.6349", + Longitude: "-90.5069", + Child: []*location{}, + }, + { + Name: "Guinea", + Acronym: "GN", + Latitude: "9.6412", + Longitude: "-13.5784", + Child: []*location{}, + }, + { + Name: "Guinea-Bissau", + Acronym: "GW", + Latitude: "11.8594", + Longitude: "-15.5855", + Child: []*location{}, + }, + { + Name: "Guyana", + Acronym: "GY", + Latitude: "6.8045", + Longitude: "-58.1553", + Child: []*location{}, + }, + { + Name: "Haiti", + Acronym: "HT", + Latitude: "18.9714", + Longitude: "-72.2852", + Child: []*location{}, + }, + { + Name: "Honduras", + Acronym: "HN", + Latitude: "14.0723", + Longitude: "-87.1921", + Child: []*location{}, + }, + { + Name: "Hungary", + Acronym: "HU", + Latitude: "47.4979", + Longitude: "19.0402", + Child: []*location{}, + }, + { + Name: "Iceland", + Acronym: "IS", + Latitude: "64.1466", + Longitude: "-21.9426", + Child: []*location{}, + }, + { + Name: "India", + Acronym: "IN", + Latitude: "28.6139", + Longitude: "77.2090", + Child: []*location{ + { + Name: "Maharashtra", + Acronym: "MH", + Latitude: "19.0760", + Longitude: "72.8777", + Child: []*location{}, + }, + { + Name: "Uttar Pradesh", + Acronym: "UP", + Latitude: "26.8467", + Longitude: "80.9462", + Child: []*location{}, + }, + { + Name: "Bihar", + Acronym: "BR", + Latitude: "25.5941", + Longitude: "85.1376", + Child: []*location{}, + }, + { + Name: "West Bengal", + Acronym: "WB", + Latitude: "22.5726", + Longitude: "88.3639", + Child: []*location{}, + }, + { + Name: "Tamil Nadu", + Acronym: "TN", + Latitude: "13.0827", + Longitude: "80.2707", + Child: []*location{}, + }, + { + Name: "Rajasthan", + Acronym: "RJ", + Latitude: "26.9124", + Longitude: "75.7873", + Child: []*location{}, + }, + { + Name: "Karnataka", + Acronym: "KA", + Latitude: "12.9716", + Longitude: "77.5946", + Child: []*location{}, + }, + { + Name: "Gujarat", + Acronym: "GJ", + Latitude: "23.2156", + Longitude: "72.6369", + Child: []*location{}, + }, + { + Name: "Andhra Pradesh", + Acronym: "AP", + Latitude: "15.9129", + Longitude: "79.7400", + Child: []*location{}, + }, + { + Name: "Odisha", + Acronym: "OD", + Latitude: "20.2961", + Longitude: "85.8245", + Child: []*location{}, + }, + { + Name: "Punjab", + Acronym: "PB", + Latitude: "31.1471", + Longitude: "75.3412", + Child: []*location{}, + }, + { + Name: "Haryana", + Acronym: "HR", + Latitude: "29.0588", + Longitude: "76.0856", + Child: []*location{}, + }, + { + Name: "Kerala", + Acronym: "KL", + Latitude: "10.8505", + Longitude: "76.2711", + Child: []*location{}, + }, + { + Name: "Jharkhand", + Acronym: "JH", + Latitude: "23.6102", + Longitude: "85.2799", + Child: []*location{}, + }, + { + Name: "Assam", + Acronym: "AS", + Latitude: "26.2006", + Longitude: "92.9376", + Child: []*location{}, + }, + { + Name: "Madhya Pradesh", + Acronym: "MP", + Latitude: "22.9734", + Longitude: "78.6569", + Child: []*location{}, + }, + { + Name: "Jammu and Kashmir", + Acronym: "JK", + Latitude: "34.0837", + Longitude: "74.7973", + Child: []*location{}, + }, + }, + }, + { + Name: "Indonesia", + Acronym: "ID", + Latitude: "-6.2088", + Longitude: "106.8456", + Child: []*location{}, + }, + { + Name: "Iran", + Acronym: "IR", + Latitude: "35.6892", + Longitude: "51.3890", + Child: []*location{}, + }, + { + Name: "Iraq", + Acronym: "IQ", + Latitude: "33.3152", + Longitude: "44.3661", + Child: []*location{}, + }, + { + Name: "Ireland", + Acronym: "IE", + Latitude: "53.3498", + Longitude: "-6.2603", + Child: []*location{}, + }, + { + Name: "Israel", + Acronym: "IL", + Latitude: "31.7683", + Longitude: "35.2137", + Child: []*location{}, + }, + { + Name: "Italy", + Acronym: "IT", + Latitude: "41.9028", + Longitude: "12.4964", + Child: []*location{ + { + Name: "Lombardy", + Acronym: "LOM", + Latitude: "45.4642", + Longitude: "9.1900", + Child: []*location{}, + }, + { + Name: "Lazio", + Acronym: "LAZ", + Latitude: "41.9028", + Longitude: "12.4964", + Child: []*location{}, + }, + { + Name: "Sicily", + Acronym: "SIC", + Latitude: "37.5399", + Longitude: "15.0805", + Child: []*location{}, + }, + { + Name: "Campania", + Acronym: "CAM", + Latitude: "40.8518", + Longitude: "14.2681", + Child: []*location{}, + }, + { + Name: "Veneto", + Acronym: "VEN", + Latitude: "45.4408", + Longitude: "12.3155", + Child: []*location{}, + }, + { + Name: "Piedmont", + Acronym: "PIE", + Latitude: "45.0703", + Longitude: "7.6869", + Child: []*location{}, + }, + { + Name: "Apulia", + Acronym: "PUG", + Latitude: "41.1171", + Longitude: "16.8719", + Child: []*location{}, + }, + { + Name: "Tuscany", + Acronym: "TOS", + Latitude: "43.7711", + Longitude: "11.2486", + Child: []*location{}, + }, + }, + }, + { + Name: "Jamaica", + Acronym: "JM", + Latitude: "17.9714", + Longitude: "-76.7931", + Child: []*location{}, + }, + { + Name: "Japan", + Acronym: "JP", + Latitude: "35.6762", + Longitude: "139.6503", + Child: []*location{ + { + Name: "Tokyo", + Acronym: "TYO", + Latitude: "35.6762", + Longitude: "139.6503", + Child: []*location{}, + }, + { + Name: "Osaka", + Acronym: "OSK", + Latitude: "34.6937", + Longitude: "135.5023", + Child: []*location{}, + }, + { + Name: "Kyoto", + Acronym: "KYT", + Latitude: "35.0116", + Longitude: "135.7681", + Child: []*location{}, + }, + { + Name: "Hokkaido", + Acronym: "HKD", + Latitude: "43.0642", + Longitude: "141.3469", + Child: []*location{}, + }, + { + Name: "Okinawa", + Acronym: "OKN", + Latitude: "26.2124", + Longitude: "127.6792", + Child: []*location{}, + }, + }, + }, + { + Name: "Jordan", + Acronym: "JO", + Latitude: "31.9632", + Longitude: "35.9304", + Child: []*location{}, + }, + { + Name: "Kazakhstan", + Acronym: "KZ", + Latitude: "51.1605", + Longitude: "71.4704", + Child: []*location{}, + }, + { + Name: "Kenya", + Acronym: "KE", + Latitude: "-1.2921", + Longitude: "36.8219", + Child: []*location{}, + }, + { + Name: "Kiribati", + Acronym: "KI", + Latitude: "1.3278", + Longitude: "172.9784", + Child: []*location{}, + }, + { + Name: "Kuwait", + Acronym: "KW", + Latitude: "29.3759", + Longitude: "47.9774", + Child: []*location{}, + }, + { + Name: "Kyrgyzstan", + Acronym: "KG", + Latitude: "42.8746", + Longitude: "74.5698", + Child: []*location{}, + }, + { + Name: "Laos", + Acronym: "LA", + Latitude: "17.9757", + Longitude: "102.6061", + Child: []*location{}, + }, + { + Name: "Latvia", + Acronym: "LV", + Latitude: "56.9496", + Longitude: "24.1052", + Child: []*location{}, + }, + { + Name: "Lebanon", + Acronym: "LB", + Latitude: "33.8938", + Longitude: "35.5018", + Child: []*location{}, + }, + { + Name: "Lesotho", + Acronym: "LS", + Latitude: "-29.3632", + Longitude: "27.5144", + Child: []*location{}, + }, + { + Name: "Liberia", + Acronym: "LR", + Latitude: "6.2905", + Longitude: "-10.7605", + Child: []*location{}, + }, + { + Name: "Libya", + Acronym: "LY", + Latitude: "32.8872", + Longitude: "13.1913", + Child: []*location{}, + }, + { + Name: "Liechtenstein", + Acronym: "LI", + Latitude: "47.1410", + Longitude: "9.5215", + Child: []*location{}, + }, + { + Name: "Lithuania", + Acronym: "LT", + Latitude: "54.6892", + Longitude: "25.2797", + Child: []*location{}, + }, + { + Name: "Luxembourg", + Acronym: "LU", + Latitude: "49.6116", + Longitude: "6.1319", + Child: []*location{}, + }, + { + Name: "Madagascar", + Acronym: "MG", + Latitude: "-18.8792", + Longitude: "47.5079", + Child: []*location{}, + }, + { + Name: "Malawi", + Acronym: "MW", + Latitude: "-14.0167", + Longitude: "33.2500", + Child: []*location{}, + }, + { + Name: "Malaysia", + Acronym: "MY", + Latitude: "3.1390", + Longitude: "101.6869", + Child: []*location{}, + }, + { + Name: "Maldives", + Acronym: "MV", + Latitude: "4.1755", + Longitude: "73.5093", + Child: []*location{}, + }, + { + Name: "Mali", + Acronym: "ML", + Latitude: "12.6392", + Longitude: "-8.0029", + Child: []*location{}, + }, + { + Name: "Malta", + Acronym: "MT", + Latitude: "35.8989", + Longitude: "14.5146", + Child: []*location{}, + }, + { + Name: "Marshall Islands", + Acronym: "MH", + Latitude: "7.1315", + Longitude: "171.1845", + Child: []*location{}, + }, + { + Name: "Mauritania", + Acronym: "MR", + Latitude: "18.0735", + Longitude: "-15.9582", + Child: []*location{}, + }, + { + Name: "Mauritius", + Acronym: "MU", + Latitude: "-20.2675", + Longitude: "57.5271", + Child: []*location{}, + }, + { + Name: "Mexico", + Acronym: "MX", + Latitude: "19.4326", + Longitude: "-99.1332", + Child: []*location{}, + }, + { + Name: "Micronesia", + Acronym: "FM", + Latitude: "6.9233", + Longitude: "158.1610", + Child: []*location{}, + }, + { + Name: "Moldova", + Acronym: "MD", + Latitude: "47.0105", + Longitude: "28.8638", + Child: []*location{}, + }, + { + Name: "Monaco", + Acronym: "MC", + Latitude: "43.7347", + Longitude: "7.4206", + Child: []*location{}, + }, + { + Name: "Mongolia", + Acronym: "MN", + Latitude: "47.9203", + Longitude: "106.9172", + Child: []*location{}, + }, + { + Name: "Montenegro", + Acronym: "ME", + Latitude: "42.4413", + Longitude: "19.2629", + Child: []*location{}, + }, + { + Name: "Morocco", + Acronym: "MA", + Latitude: "33.9716", + Longitude: "-6.8498", + Child: []*location{}, + }, + { + Name: "Mozambique", + Acronym: "MZ", + Latitude: "-25.9692", + Longitude: "32.5832", + Child: []*location{}, + }, + { + Name: "Myanmar", + Acronym: "MM", + Latitude: "19.7633", + Longitude: "96.0785", + Child: []*location{}, + }, + { + Name: "Namibia", + Acronym: "NA", + Latitude: "-22.5609", + Longitude: "17.0658", + Child: []*location{}, + }, + { + Name: "Nauru", + Acronym: "NR", + Latitude: "-0.5478", + Longitude: "166.9313", + Child: []*location{}, + }, + { + Name: "Nepal", + Acronym: "NP", + Latitude: "27.7172", + Longitude: "85.3240", + Child: []*location{}, + }, + { + Name: "Netherlands", + Acronym: "NL", + Latitude: "52.3676", + Longitude: "4.9041", + Child: []*location{}, + }, + { + Name: "New Zealand", + Acronym: "NZ", + Latitude: "-41.2865", + Longitude: "174.7762", + Child: []*location{}, + }, + { + Name: "Nicaragua", + Acronym: "NI", + Latitude: "12.1144", + Longitude: "-86.2362", + Child: []*location{}, + }, + { + Name: "Niger", + Acronym: "NE", + Latitude: "13.5116", + Longitude: "2.1254", + Child: []*location{}, + }, + { + Name: "Nigeria", + Acronym: "NG", + Latitude: "6.5244", + Longitude: "3.3792", + Child: []*location{}, + }, + { + Name: "North Macedonia", + Acronym: "MK", + Latitude: "41.9981", + Longitude: "21.4254", + Child: []*location{}, + }, + { + Name: "Norway", + Acronym: "NO", + Latitude: "59.9139", + Longitude: "10.7522", + Child: []*location{}, + }, + { + Name: "Oman", + Acronym: "OM", + Latitude: "23.5859", + Longitude: "58.4059", + Child: []*location{}, + }, + { + Name: "Pakistan", + Acronym: "PK", + Latitude: "33.6844", + Longitude: "73.0479", + Child: []*location{}, + }, + { + Name: "Palau", + Acronym: "PW", + Latitude: "7.51498", + Longitude: "134.58252", + Child: []*location{}, + }, + { + Name: "Panama", + Acronym: "PA", + Latitude: "8.9824", + Longitude: "-79.5199", + Child: []*location{}, + }, + { + Name: "Papua New Guinea", + Acronym: "PG", + Latitude: "-9.4438", + Longitude: "147.1803", + Child: []*location{}, + }, + { + Name: "Paraguay", + Acronym: "PY", + Latitude: "-25.2637", + Longitude: "-57.5759", + Child: []*location{}, + }, + { + Name: "Peru", + Acronym: "PE", + Latitude: "-12.0464", + Longitude: "-77.0428", + Child: []*location{}, + }, + { + Name: "Philippines", + Acronym: "PH", + Latitude: "14.5995", + Longitude: "120.9842", + Child: []*location{}, + }, + { + Name: "Poland", + Acronym: "PL", + Latitude: "52.2297", + Longitude: "21.0122", + Child: []*location{}, + }, + { + Name: "Portugal", + Acronym: "PT", + Latitude: "38.7223", + Longitude: "-9.1393", + Child: []*location{}, + }, + { + Name: "Qatar", + Acronym: "QA", + Latitude: "25.2854", + Longitude: "51.5310", + Child: []*location{}, + }, + { + Name: "Romania", + Acronym: "RO", + Latitude: "44.4268", + Longitude: "26.1025", + Child: []*location{}, + }, + { + Name: "Russia", + Acronym: "RU", + Latitude: "55.7558", + Longitude: "37.6173", + Child: []*location{}, + }, + { + Name: "Rwanda", + Acronym: "RW", + Latitude: "-1.9403", + Longitude: "29.8739", + Child: []*location{}, + }, + { + Name: "Saint Kitts and Nevis", + Acronym: "KN", + Latitude: "17.2923", + Longitude: "-62.7325", + Child: []*location{}, + }, + { + Name: "Saint Lucia", + Acronym: "LC", + Latitude: "13.9074", + Longitude: "-60.9789", + Child: []*location{}, + }, + { + Name: "Saint Vincent and the Grenadines", + Acronym: "VC", + Latitude: "13.1605", + Longitude: "-61.2226", + Child: []*location{}, + }, + { + Name: "Samoa", + Acronym: "WS", + Latitude: "-13.8506", + Longitude: "-171.7513", + Child: []*location{}, + }, + { + Name: "San Marino", + Acronym: "SM", + Latitude: "43.9424", + Longitude: "12.4578", + Child: []*location{}, + }, + { + Name: "São Tomé and Príncipe", + Acronym: "ST", + Latitude: "0.3364", + Longitude: "6.7304", + Child: []*location{}, + }, + { + Name: "Saudi Arabia", + Acronym: "SA", + Latitude: "24.7136", + Longitude: "46.6753", + Child: []*location{}, + }, + { + Name: "Senegal", + Acronym: "SN", + Latitude: "14.7167", + Longitude: "-17.4677", + Child: []*location{}, + }, + { + Name: "Serbia", + Acronym: "RS", + Latitude: "44.7866", + Longitude: "20.4489", + Child: []*location{}, + }, + { + Name: "Seychelles", + Acronym: "SC", + Latitude: "-4.6796", + Longitude: "55.4920", + Child: []*location{}, + }, + { + Name: "Sierra Leone", + Acronym: "SL", + Latitude: "8.4657", + Longitude: "-13.2317", + Child: []*location{}, + }, + { + Name: "Singapore", + Acronym: "SG", + Latitude: "1.3521", + Longitude: "103.8198", + Child: []*location{}, + }, + { + Name: "Slovakia", + Acronym: "SK", + Latitude: "48.1476", + Longitude: "17.1077", + Child: []*location{}, + }, + { + Name: "Slovenia", + Acronym: "SI", + Latitude: "46.0569", + Longitude: "14.5058", + Child: []*location{}, + }, + { + Name: "Solomon Islands", + Acronym: "SB", + Latitude: "-9.4456", + Longitude: "159.9728", + Child: []*location{}, + }, + { + Name: "Somalia", + Acronym: "SO", + Latitude: "2.0469", + Longitude: "45.3182", + Child: []*location{}, + }, + { + Name: "South Africa", + Acronym: "ZA", + Latitude: "-25.7479", + Longitude: "28.2293", + Child: []*location{}, + }, + { + Name: "South Korea", + Acronym: "KR", + Latitude: "37.5665", + Longitude: "126.9780", + Child: []*location{ + { + Name: "Seoul", + Acronym: "SEO", + Latitude: "37.5665", + Longitude: "126.9780", + Child: []*location{}, + }, + { + Name: "Busan", + Acronym: "BUS", + Latitude: "35.1796", + Longitude: "129.0756", + Child: []*location{}, + }, + { + Name: "Daegu", + Acronym: "DAE", + Latitude: "35.8722", + Longitude: "128.6025", + Child: []*location{}, + }, + { + Name: "Incheon", + Acronym: "INC", + Latitude: "37.4563", + Longitude: "126.7052", + Child: []*location{}, + }, + { + Name: "Gwangju", + Acronym: "GWJ", + Latitude: "35.1595", + Longitude: "126.8526", + Child: []*location{}, + }, + { + Name: "Daejeon", + Acronym: "DAE", + Latitude: "36.3504", + Longitude: "127.3845", + Child: []*location{}, + }, + { + Name: "Ulsan", + Acronym: "ULS", + Latitude: "35.5384", + Longitude: "129.3114", + Child: []*location{}, + }, + { + Name: "Gyeonggi Province", + Acronym: "GG", + Latitude: "37.2752", + Longitude: "127.0095", + Child: []*location{}, + }, + { + Name: "Gangwon Province", + Acronym: "GW", + Latitude: "37.5558", + Longitude: "128.2093", + Child: []*location{}, + }, + { + Name: "North Chungcheong Province", + Acronym: "NC", + Latitude: "36.6284", + Longitude: "127.9288", + Child: []*location{}, + }, + { + Name: "South Chungcheong Province", + Acronym: "SC", + Latitude: "36.5581", + Longitude: "126.7990", + Child: []*location{}, + }, + { + Name: "North Jeolla Province", + Acronym: "NJ", + Latitude: "35.7175", + Longitude: "127.1539", + Child: []*location{}, + }, + { + Name: "South Jeolla Province", + Acronym: "SJ", + Latitude: "34.8160", + Longitude: "126.9218", + Child: []*location{}, + }, + { + Name: "North Gyeongsang Province", + Acronym: "NG", + Latitude: "36.4919", + Longitude: "128.8889", + Child: []*location{}, + }, + { + Name: "South Gyeongsang Province", + Acronym: "SG", + Latitude: "35.2380", + Longitude: "128.6919", + Child: []*location{}, + }, + { + Name: "Jeju Province", + Acronym: "JJ", + Latitude: "33.4996", + Longitude: "126.5312", + Child: []*location{}, + }, + }, + }, + { + Name: "South Sudan", + Acronym: "SS", + Latitude: "4.8594", + Longitude: "31.5713", + Child: []*location{}, + }, + { + Name: "Spain", + Acronym: "ES", + Latitude: "40.4168", + Longitude: "-3.7038", + Child: []*location{}, + }, + { + Name: "Sri Lanka", + Acronym: "LK", + Latitude: "6.9271", + Longitude: "79.8612", + Child: []*location{}, + }, + { + Name: "Sudan", + Acronym: "SD", + Latitude: "15.5007", + Longitude: "32.5599", + Child: []*location{}, + }, + { + Name: "Suriname", + Acronym: "SR", + Latitude: "5.8520", + Longitude: "-55.2038", + Child: []*location{}, + }, + { + Name: "Sweden", + Acronym: "SE", + Latitude: "59.3293", + Longitude: "18.0686", + Child: []*location{}, + }, + { + Name: "Switzerland", + Acronym: "CH", + Latitude: "46.9481", + Longitude: "7.4474", + Child: []*location{}, + }, + { + Name: "Syria", + Acronym: "SY", + Latitude: "33.5138", + Longitude: "36.2765", + Child: []*location{}, + }, + { + Name: "Taiwan", + Acronym: "TW", + Latitude: "25.0330", + Longitude: "121.5654", + Child: []*location{}, + }, + { + Name: "Tajikistan", + Acronym: "TJ", + Latitude: "38.5598", + Longitude: "68.7864", + Child: []*location{}, + }, + { + Name: "Tanzania", + Acronym: "TZ", + Latitude: "-6.7924", + Longitude: "39.2083", + Child: []*location{}, + }, + { + Name: "Thailand", + Acronym: "TH", + Latitude: "13.7563", + Longitude: "100.5018", + Child: []*location{}, + }, + { + Name: "Togo", + Acronym: "TG", + Latitude: "6.1395", + Longitude: "1.2255", + Child: []*location{}, + }, + { + Name: "Tonga", + Acronym: "TO", + Latitude: "-21.1789", + Longitude: "-175.1982", + Child: []*location{}, + }, + { + Name: "Trinidad and Tobago", + Acronym: "TT", + Latitude: "10.6918", + Longitude: "-61.2225", + Child: []*location{}, + }, + { + Name: "Tunisia", + Acronym: "TN", + Latitude: "36.8065", + Longitude: "10.1815", + Child: []*location{}, + }, + { + Name: "Turkey", + Acronym: "TR", + Latitude: "39.9334", + Longitude: "32.8597", + Child: []*location{}, + }, + { + Name: "Turkmenistan", + Acronym: "TM", + Latitude: "37.9601", + Longitude: "58.3261", + Child: []*location{}, + }, + { + Name: "Tuvalu", + Acronym: "TV", + Latitude: "-8.5244", + Longitude: "179.1906", + Child: []*location{}, + }, + { + Name: "Uganda", + Acronym: "UG", + Latitude: "0.3476", + Longitude: "32.5825", + Child: []*location{}, + }, + { + Name: "Ukraine", + Acronym: "UA", + Latitude: "50.4501", + Longitude: "30.5234", + Child: []*location{}, + }, + { + Name: "United Arab Emirates", + Acronym: "AE", + Latitude: "25.2769", + Longitude: "55.2962", + Child: []*location{}, + }, + { + Name: "United Kingdom", + Acronym: "GB", + Latitude: "51.5074", + Longitude: "-0.1278", + Child: []*location{ + { + Name: "England", + Acronym: "ENG", + Latitude: "52.3555", + Longitude: "-1.1743", + Child: []*location{}, + }, + { + Name: "Scotland", + Acronym: "SCT", + Latitude: "56.4907", + Longitude: "-4.2026", + Child: []*location{}, + }, + { + Name: "Wales", + Acronym: "WLS", + Latitude: "52.1307", + Longitude: "-3.7837", + Child: []*location{}, + }, + { + Name: "Northern Ireland", + Acronym: "NIR", + Latitude: "54.7877", + Longitude: "-6.4923", + Child: []*location{}, + }, + }, + }, + { + Name: "United States", + Acronym: "US", + Latitude: "38.9072", + Longitude: "-77.0369", + Child: []*location{ + { + Name: "California", + Acronym: "CA", + Latitude: "36.7783", + Longitude: "-119.4179", + Child: []*location{}, + }, + { + Name: "New York", + Acronym: "NY", + Latitude: "43.0000", + Longitude: "-75.0000", + Child: []*location{}, + }, + { + Name: "Texas", + Acronym: "TX", + Latitude: "31.0000", + Longitude: "-100.0000", + Child: []*location{}, + }, + { + Name: "Florida", + Acronym: "FL", + Latitude: "27.7663", + Longitude: "-82.4754", + Child: []*location{}, + }, + { + Name: "Hawaii", + Acronym: "HI", + Latitude: "21.3099", + Longitude: "-157.8581", + Child: []*location{}, + }, + { + Name: "Alaska", + Acronym: "AK", + Latitude: "61.0000", + Longitude: "-150.0000", + Child: []*location{}, + }, + { + Name: "Washington", + Acronym: "WA", + Latitude: "47.7511", + Longitude: "-120.7401", + Child: []*location{}, + }, + { + Name: "Massachusetts", + Acronym: "MA", + Latitude: "42.4072", + Longitude: "-71.3824", + Child: []*location{}, + }, + { + Name: "Pennsylvania", + Acronym: "PA", + Latitude: "41.2033", + Longitude: "-77.1945", + Child: []*location{}, + }, + { + Name: "Illinois", + Acronym: "IL", + Latitude: "40.6331", + Longitude: "-89.3985", + Child: []*location{}, + }, + { + Name: "Ohio", + Acronym: "OH", + Latitude: "40.4173", + Longitude: "-82.9071", + Child: []*location{}, + }, + { + Name: "Michigan", + Acronym: "MI", + Latitude: "44.3148", + Longitude: "-85.6024", + Child: []*location{}, + }, + { + Name: "New Jersey", + Acronym: "NJ", + Latitude: "40.0583", + Longitude: "-74.4057", + Child: []*location{}, + }, + { + Name: "Virginia", + Acronym: "VA", + Latitude: "37.4316", + Longitude: "-78.6569", + Child: []*location{}, + }, + { + Name: "North Carolina", + Acronym: "NC", + Latitude: "35.7596", + Longitude: "-79.0193", + Child: []*location{}, + }, + { + Name: "Georgia", + Acronym: "GA", + Latitude: "32.1656", + Longitude: "-82.9001", + Child: []*location{}, + }, + }, + }, + { + Name: "Uruguay", + Acronym: "UY", + Latitude: "-34.8836", + Longitude: "-56.1819", + Child: []*location{}, + }, + { + Name: "Uzbekistan", + Acronym: "UZ", + Latitude: "41.2995", + Longitude: "69.2401", + Child: []*location{}, + }, + { + Name: "Vanuatu", + Acronym: "VU", + Latitude: "-17.7338", + Longitude: "168.3215", + Child: []*location{}, + }, + { + Name: "Vatican City", + Acronym: "VA", + Latitude: "41.9029", + Longitude: "12.4534", + Child: []*location{}, + }, + { + Name: "Venezuela", + Acronym: "VE", + Latitude: "10.4806", + Longitude: "-66.9036", + Child: []*location{}, + }, + { + Name: "Vietnam", + Acronym: "VN", + Latitude: "21.0285", + Longitude: "105.8542", + Child: []*location{}, + }, + { + Name: "Yemen", + Acronym: "YE", + Latitude: "15.3694", + Longitude: "44.1910", + Child: []*location{}, + }, + { + Name: "Zambia", + Acronym: "ZM", + Latitude: "-15.3875", + Longitude: "28.3228", + Child: []*location{}, + }, + { + Name: "Zimbabwe", + Acronym: "ZW", + Latitude: "-17.8252", + Longitude: "31.0335", + Child: []*location{}, + }, + } +) + +// GetCapitalCoordinates retrieves capital coordinates by country name, code, or province/state name +// Supports both single input and combined country+region input (e.g., "CN, Beijing" or "US, California") +func GetCapitalCoordinates(input string) (latitude, longitude string, found bool) { + if input == "" { + return "", "", false + } + + input = strings.TrimSpace(input) + + // Check if input is empty after trimming + if input == "" { + return "", "", false + } + + // Check if input contains comma (combined country+region) + if strings.Contains(input, ",") { + return handleCombinedInput(input) + } + + // Handle single input (existing logic) + inputLower := strings.ToLower(input) + + // First, search through all provinces/states to prioritize them over countries with same acronyms + for _, country := range center { + // Check if input matches any child (province/state) - return country's capital coordinates + for _, child := range country.Child { + if strings.ToLower(child.Name) == inputLower || strings.ToLower(child.Acronym) == inputLower { + return country.Latitude, country.Longitude, true + } + } + } + + // Then search through all countries (only if no province/state match found) + for _, country := range center { + // Check exact country name match + if strings.ToLower(country.Name) == inputLower { + return country.Latitude, country.Longitude, true + } + + // Check country acronym match + if strings.ToLower(country.Acronym) == inputLower { + return country.Latitude, country.Longitude, true + } + } + + // If no exact match, try fuzzy matching for country names + for _, country := range center { + if strings.Contains(strings.ToLower(country.Name), inputLower) || + strings.Contains(inputLower, strings.ToLower(country.Name)) { + return country.Latitude, country.Longitude, true + } + } + + // If still no match, try fuzzy matching for province/state names + for _, country := range center { + for _, child := range country.Child { + if strings.Contains(strings.ToLower(child.Name), inputLower) || + strings.Contains(inputLower, strings.ToLower(child.Name)) { + return country.Latitude, country.Longitude, true + } + } + } + + return "", "", false +} + +// handleCombinedInput processes input with comma separating country and region +func handleCombinedInput(input string) (latitude, longitude string, found bool) { + parts := strings.Split(input, ",") + if len(parts) != 2 { + return "", "", false + } + + countryPart := strings.TrimSpace(parts[0]) + regionPart := strings.TrimSpace(parts[1]) + + countryLower := strings.ToLower(countryPart) + regionLower := strings.ToLower(regionPart) + + // First find the matching country + var matchedCountry *location + for _, country := range center { + if strings.ToLower(country.Name) == countryLower || + strings.ToLower(country.Acronym) == countryLower { + matchedCountry = &country + break + } + } + + // If no exact country match, try fuzzy matching + if matchedCountry == nil { + for _, country := range center { + if strings.Contains(strings.ToLower(country.Name), countryLower) || + strings.Contains(countryLower, strings.ToLower(country.Name)) { + matchedCountry = &country + break + } + } + } + + // If still no country match, try to find the country that contains the province/state matching countryPart + if matchedCountry == nil { + for _, country := range center { + for _, child := range country.Child { + if strings.ToLower(child.Name) == countryLower || + strings.ToLower(child.Acronym) == countryLower { + matchedCountry = &country + break + } + } + if matchedCountry != nil { + break + } + } + } + + // If still no match, try fuzzy matching for provinces/states + if matchedCountry == nil { + for _, country := range center { + for _, child := range country.Child { + if strings.Contains(strings.ToLower(child.Name), countryLower) || + strings.Contains(countryLower, strings.ToLower(child.Name)) { + matchedCountry = &country + break + } + } + if matchedCountry != nil { + break + } + } + } + + // If still no match, return false + if matchedCountry == nil { + return "", "", false + } + + // Check if region matches any child of the found country + for _, child := range matchedCountry.Child { + if strings.ToLower(child.Name) == regionLower || + strings.ToLower(child.Acronym) == regionLower { + // Return the country's capital coordinates + return matchedCountry.Latitude, matchedCountry.Longitude, true + } + } + + // If no exact region match, try fuzzy matching within the country's children + for _, child := range matchedCountry.Child { + if strings.Contains(strings.ToLower(child.Name), regionLower) || + strings.Contains(regionLower, strings.ToLower(child.Name)) { + return matchedCountry.Latitude, matchedCountry.Longitude, true + } + } + + // If region doesn't match but country does, return country's capital coordinates + return matchedCountry.Latitude, matchedCountry.Longitude, true +} diff --git a/pkg/ip/ip.go b/pkg/ip/ip.go index b39eda4..ecb2ca7 100644 --- a/pkg/ip/ip.go +++ b/pkg/ip/ip.go @@ -55,6 +55,15 @@ var ( // GetRegionByIp queries the geolocation of an IP address using supported services. func GetRegionByIp(ip string) (*GeoLocationResponse, error) { + // 如果是域名,先解析成 IP + if net.ParseIP(ip) == nil { + ips, err := GetIP(ip) + if err != nil || len(ips) == 0 { + return nil, errors.Wrap(err, "无法解析域名为IP") + } + ip = ips[0] // 取第一个解析到的IP + } + for service, enabled := range queryUrls { if enabled { response, err := fetchGeolocation(service, ip) @@ -62,6 +71,15 @@ func GetRegionByIp(ip string) (*GeoLocationResponse, error) { zap.S().Errorf("Failed to fetch geolocation from %s: %v", service, err) continue } + if response.Country == "" { + continue + } + response.LatitudeCenter, response.LongitudeCenter, _ = GetCapitalCoordinates(fmt.Sprintf("%s,%s", response.Country, response.City)) + if response.LatitudeCenter == "" || response.LongitudeCenter == "" { + response.LatitudeCenter = response.Latitude + response.LongitudeCenter = response.Longitude + + } return response, nil } } @@ -169,11 +187,13 @@ func decompressResponse(resp *http.Response) ([]byte, error) { // GeoLocationResponse represents the geolocation data returned by the API. type GeoLocationResponse struct { - Country string `json:"country"` - CountryName string `json:"country_name"` - Region string `json:"region"` - City string `json:"city"` - Latitude string `json:"latitude"` - Longitude string `json:"longitude"` - Loc string `json:"loc"` + Country string `json:"country"` + CountryName string `json:"country_name"` + Region string `json:"region"` + City string `json:"city"` + Latitude string `json:"latitude"` + Longitude string `json:"longitude"` + LatitudeCenter string `json:"latitude_center"` + LongitudeCenter string `json:"longitude_center"` + Loc string `json:"loc"` } diff --git a/pkg/tool/encryption.go b/pkg/tool/encryption.go index f51f61a..d34f102 100644 --- a/pkg/tool/encryption.go +++ b/pkg/tool/encryption.go @@ -46,12 +46,11 @@ func MultiPasswordVerify(algo, salt, password, hash string) bool { case "md5salt": sum := md5.Sum([]byte(password + salt)) return hex.EncodeToString(sum[:]) == hash - case "default": // PPanel's default algorithm - return VerifyPassWord(password, hash) case "bcrypt": // Bcrypt (corresponding to PHP's password_hash/password_verify) err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) return err == nil + default: + return VerifyPassWord(password, hash) } - return false } diff --git a/pkg/updater/updater.go b/pkg/updater/updater.go new file mode 100644 index 0000000..ff87e36 --- /dev/null +++ b/pkg/updater/updater.go @@ -0,0 +1,382 @@ +package updater + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/perfect-panel/server/pkg/constant" +) + +const ( + githubAPIURL = "https://api.github.com/repos/OmnTeam/server/releases/latest" + githubRelURL = "https://github.com/OmnTeam/server/releases" +) + +// Release represents a GitHub release +type Release struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + Assets []Asset `json:"assets"` + HTMLURL string `json:"html_url"` +} + +// Asset represents a release asset +type Asset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Size int64 `json:"size"` +} + +// Updater handles auto-update functionality +type Updater struct { + CurrentVersion string + Owner string + Repo string + HTTPClient *http.Client +} + +// NewUpdater creates a new updater instance +func NewUpdater() *Updater { + return &Updater{ + CurrentVersion: constant.Version, + Owner: "OmnTeam", + Repo: "server", + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// CheckForUpdates checks if a new version is available +func (u *Updater) CheckForUpdates() (*Release, bool, error) { + req, err := http.NewRequest("GET", githubAPIURL, nil) + if err != nil { + return nil, false, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := u.HTTPClient.Do(req) + if err != nil { + return nil, false, fmt.Errorf("failed to fetch release info: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, false, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var release Release + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, false, fmt.Errorf("failed to decode response: %w", err) + } + + // Skip draft and prerelease versions + if release.Draft || release.Prerelease { + return nil, false, nil + } + + // Compare versions + hasUpdate := u.compareVersions(release.TagName, u.CurrentVersion) + return &release, hasUpdate, nil +} + +// compareVersions compares two version strings +// Returns true if newVersion is newer than currentVersion +func (u *Updater) compareVersions(newVersion, currentVersion string) bool { + // Remove 'v' prefix if present + newVersion = strings.TrimPrefix(newVersion, "v") + currentVersion = strings.TrimPrefix(currentVersion, "v") + + // Handle "unknown version" case + if currentVersion == "unknown version" || currentVersion == "" { + return true + } + + return newVersion != currentVersion +} + +// Download downloads the appropriate binary for the current platform +func (u *Updater) Download(release *Release) (string, error) { + assetName := u.getAssetName() + + var targetAsset *Asset + for _, asset := range release.Assets { + if asset.Name == assetName { + targetAsset = &asset + break + } + } + + if targetAsset == nil { + return "", fmt.Errorf("no suitable asset found for %s", assetName) + } + + // Create temp directory + tempDir, err := os.MkdirTemp("", "ppanel-update-*") + if err != nil { + return "", fmt.Errorf("failed to create temp directory: %w", err) + } + + // Download the file + resp, err := u.HTTPClient.Get(targetAsset.BrowserDownloadURL) + if err != nil { + os.RemoveAll(tempDir) + return "", fmt.Errorf("failed to download asset: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + os.RemoveAll(tempDir) + return "", fmt.Errorf("failed to download: status code %d", resp.StatusCode) + } + + // Read the entire file into memory + data, err := io.ReadAll(resp.Body) + if err != nil { + os.RemoveAll(tempDir) + return "", fmt.Errorf("failed to read download: %w", err) + } + + // Extract the binary + binaryPath, err := u.extractBinary(data, tempDir, assetName) + if err != nil { + os.RemoveAll(tempDir) + return "", fmt.Errorf("failed to extract binary: %w", err) + } + + return binaryPath, nil +} + +// getAssetName returns the expected asset name for the current platform +func (u *Updater) getAssetName() string { + goos := runtime.GOOS + goarch := runtime.GOARCH + + // Capitalize first letter of OS + osName := strings.Title(goos) + + // Map architecture names to match goreleaser output + archName := goarch + switch goarch { + case "amd64": + archName = "x86_64" + case "386": + archName = "i386" + } + + // Format: ppanel-server-{Version}-{Os}-{Arch}.{ext} + ext := "tar.gz" + if goos == "windows" { + ext = "zip" + } + + return fmt.Sprintf("ppanel-server-%s-%s-%s.%s", u.CurrentVersion, osName, archName, ext) +} + +// extractBinary extracts the binary from the downloaded archive +func (u *Updater) extractBinary(data []byte, destDir, assetName string) (string, error) { + if strings.HasSuffix(assetName, ".zip") { + return u.extractZip(data, destDir) + } + return u.extractTarGz(data, destDir) +} + +// extractZip extracts a zip archive +func (u *Updater) extractZip(data []byte, destDir string) (string, error) { + reader := bytes.NewReader(data) + zipReader, err := zip.NewReader(reader, int64(len(data))) + if err != nil { + return "", fmt.Errorf("failed to create zip reader: %w", err) + } + + var binaryPath string + for _, file := range zipReader.File { + // Look for the binary file + if strings.Contains(file.Name, "ppanel-server") && !strings.Contains(file.Name, "/") { + binaryPath = filepath.Join(destDir, filepath.Base(file.Name)) + + rc, err := file.Open() + if err != nil { + return "", fmt.Errorf("failed to open file in zip: %w", err) + } + defer rc.Close() + + outFile, err := os.OpenFile(binaryPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + return "", fmt.Errorf("failed to create output file: %w", err) + } + defer outFile.Close() + + if _, err := io.Copy(outFile, rc); err != nil { + return "", fmt.Errorf("failed to write file: %w", err) + } + + return binaryPath, nil + } + } + + return "", fmt.Errorf("binary not found in archive") +} + +// extractTarGz extracts a tar.gz archive +func (u *Updater) extractTarGz(data []byte, destDir string) (string, error) { + reader := bytes.NewReader(data) + gzReader, err := gzip.NewReader(reader) + if err != nil { + return "", fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzReader.Close() + + tarReader := tar.NewReader(gzReader) + + var binaryPath string + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return "", fmt.Errorf("failed to read tar: %w", err) + } + + // Look for the binary file + if strings.Contains(header.Name, "ppanel-server") && !strings.Contains(header.Name, "/") { + binaryPath = filepath.Join(destDir, filepath.Base(header.Name)) + + outFile, err := os.OpenFile(binaryPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + return "", fmt.Errorf("failed to create output file: %w", err) + } + defer outFile.Close() + + if _, err := io.Copy(outFile, tarReader); err != nil { + return "", fmt.Errorf("failed to write file: %w", err) + } + + return binaryPath, nil + } + } + + return "", fmt.Errorf("binary not found in archive") +} + +// Apply applies the update by replacing the current binary +func (u *Updater) Apply(newBinaryPath string) error { + // Get current executable path + currentPath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get current executable path: %w", err) + } + + // Resolve symlinks + currentPath, err = filepath.EvalSymlinks(currentPath) + if err != nil { + return fmt.Errorf("failed to resolve symlinks: %w", err) + } + + // Create backup + backupPath := currentPath + ".backup" + if err := u.copyFile(currentPath, backupPath); err != nil { + return fmt.Errorf("failed to create backup: %w", err) + } + + // Replace the binary + if err := u.replaceFile(newBinaryPath, currentPath); err != nil { + // Restore backup on failure + u.copyFile(backupPath, currentPath) + return fmt.Errorf("failed to replace binary: %w", err) + } + + // Remove backup on success + os.Remove(backupPath) + + return nil +} + +// copyFile copies a file from src to dst +func (u *Updater) copyFile(src, dst string) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + return err + } + defer dstFile.Close() + + if _, err := io.Copy(dstFile, srcFile); err != nil { + return err + } + + return dstFile.Sync() +} + +// replaceFile replaces dst with src +func (u *Updater) replaceFile(src, dst string) error { + // On Windows, we need to rename the old file first + if runtime.GOOS == "windows" { + oldPath := dst + ".old" + if err := os.Rename(dst, oldPath); err != nil { + return err + } + defer os.Remove(oldPath) + } + + // Copy the new file + if err := u.copyFile(src, dst); err != nil { + return err + } + + return nil +} + +// Update performs the complete update process +func (u *Updater) Update() error { + // Check for updates + release, hasUpdate, err := u.CheckForUpdates() + if err != nil { + return fmt.Errorf("failed to check for updates: %w", err) + } + + if !hasUpdate { + return fmt.Errorf("already running the latest version") + } + + fmt.Printf("New version available: %s\n", release.TagName) + fmt.Printf("Downloading update...\n") + + // Download the update + binaryPath, err := u.Download(release) + if err != nil { + return fmt.Errorf("failed to download update: %w", err) + } + defer os.RemoveAll(filepath.Dir(binaryPath)) + + fmt.Printf("Applying update...\n") + + // Apply the update + if err := u.Apply(binaryPath); err != nil { + return fmt.Errorf("failed to apply update: %w", err) + } + + fmt.Printf("Update completed successfully! Please restart the application.\n") + return nil +} diff --git a/pkg/updater/updater_test.go b/pkg/updater/updater_test.go new file mode 100644 index 0000000..bc703a3 --- /dev/null +++ b/pkg/updater/updater_test.go @@ -0,0 +1,74 @@ +package updater + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewUpdater(t *testing.T) { + u := NewUpdater() + assert.NotNil(t, u) + assert.Equal(t, "OmnTeam", u.Owner) + assert.Equal(t, "server", u.Repo) + assert.NotNil(t, u.HTTPClient) +} + +func TestCompareVersions(t *testing.T) { + u := NewUpdater() + + tests := []struct { + name string + newVersion string + currentVersion string + expected bool + }{ + { + name: "same version", + newVersion: "v1.0.0", + currentVersion: "v1.0.0", + expected: false, + }, + { + name: "different version", + newVersion: "v1.1.0", + currentVersion: "v1.0.0", + expected: true, + }, + { + name: "unknown current version", + newVersion: "v1.0.0", + currentVersion: "unknown version", + expected: true, + }, + { + name: "version without v prefix", + newVersion: "1.1.0", + currentVersion: "1.0.0", + expected: true, + }, + { + name: "empty current version", + newVersion: "v1.0.0", + currentVersion: "", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := u.compareVersions(tt.newVersion, tt.currentVersion) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetAssetName(t *testing.T) { + u := NewUpdater() + u.CurrentVersion = "v1.0.0" + + assetName := u.getAssetName() + assert.NotEmpty(t, assetName) + assert.Contains(t, assetName, "ppanel-server") + assert.Contains(t, assetName, "v1.0.0") +} diff --git a/pkg/xerr/errCode.go b/pkg/xerr/errCode.go index fd9d098..c37e9b9 100644 --- a/pkg/xerr/errCode.go +++ b/pkg/xerr/errCode.go @@ -28,6 +28,7 @@ const ( UserNotBindOauth uint32 = 20008 InviteCodeError uint32 = 20009 UserCommissionNotEnough uint32 = 20010 + RegisterIPLimit uint32 = 20011 ) // Node error diff --git a/pkg/xerr/errMsg.go b/pkg/xerr/errMsg.go index 1814720..ed054ae 100644 --- a/pkg/xerr/errMsg.go +++ b/pkg/xerr/errMsg.go @@ -33,6 +33,7 @@ func init() { TelegramNotBound: "Telegram not bound ", UserNotBindOauth: "User not bind oauth method", InviteCodeError: "Invite code error", + RegisterIPLimit: "Too many registrations", // Node error NodeExist: "Node already exists", diff --git a/ppanel.api b/ppanel.api index 10c83c2..3e6f0d9 100644 --- a/ppanel.api +++ b/ppanel.api @@ -19,6 +19,7 @@ import ( "apis/admin/subscribe.api" "apis/admin/payment.api" "apis/admin/coupon.api" + "apis/admin/redemption.api" "apis/admin/order.api" "apis/admin/ticket.api" "apis/admin/announcement.api" @@ -31,6 +32,7 @@ import ( "apis/admin/application.api" "apis/public/user.api" "apis/public/subscribe.api" + "apis/public/redemption.api" "apis/public/order.api" "apis/public/announcement.api" "apis/public/ticket.api"