diff --git a/.cruft.json b/.cruft.json
deleted file mode 100644
index 9342982..0000000
--- a/.cruft.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "template": "https://github.com/OVINC-CN/DevTemplateDjango.git",
- "commit": "394459732ddce4619ad7a43cb96b5e3694f97c6a",
- "checkout": "main",
- "context": {
- "cookiecutter": {
- "project_name": "union-api",
- "_copy_without_render": [
- ".github"
- ],
- "_template": "https://github.com/OVINC-CN/DevTemplateDjango.git"
- }
- },
- "directory": null
-}
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..ade0662
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,37 @@
+# Build In Base
+APP_NAME=api-center
+APP_DEBUG=false
+APP_TIMEZONE=Asia/Shanghai
+# Build In Log
+APP_LOG_LEVEL=INFO
+APP_LOG_ENCODING=json
+APP_LOG_OUTPUT=stdout
+# Build In MySQL
+APP_MYSQL_USERNAME=root
+APP_MYSQL_PASSWORD=
+APP_MYSQL_ADDR=127.0.0.1:3306
+APP_MYSQL_DATABASE=api_center
+APP_MYSQL_CONNECT_ARGS=charset=utf8mb4&parseTime=True&loc=UTC
+APP_MYSQL_MAX_OPEN_CONNS=100
+APP_MYSQL_MAX_IDLE_CONNS=10
+APP_MYSQL_CONN_MAX_LIFETIME=3600
+# Build In Redis
+APP_REDIS_ADDR=127.0.0.1:6379
+APP_REDIS_PASSWORD=
+APP_REDIS_DB=0
+# Build In Trace
+APP_ENABLE_TRACE=true
+APP_TRACE_ENDPOINT=127.0.0.1:4317
+# Api
+APP_API_ADDR=0.0.0.0:8000
+APP_API_COOKIE_DOMAIN=
+APP_API_COOKIE_SECURE=true
+APP_API_COOKIE_HTTP_ONLY=true
+# Cron
+APP_CRON_WORKER_CONCURRENCY=1
+APP_CRON_WORKER_QUEUES={"default": 1}
+APP_CRON_WORKER_SHUTDOWN_TIMEOUT=60
+# App Account
+APP_ACCOUNT_EMAIL_DOMAIN=example.com
+APP_ACCOUNT_AUTH_TOKEN_TIMEOUT=604800
+APP_ACCOUNT_AUTH_TOKEN_COOKIE_NAME=auth-token
\ No newline at end of file
diff --git a/.flake8 b/.flake8
deleted file mode 100644
index 16e391b..0000000
--- a/.flake8
+++ /dev/null
@@ -1,24 +0,0 @@
-[flake8]
-exclude =
- .git,
- __pycache__,
- *migrations*,
- *.pyc,
-
-max-line-length = 120
-max-complexity = 25
-format=pylint
-show_source = True
-statistics = True
-count = True
-select = C,E,F,W,B,B950
-ignore =
- F405,
- F9,
- E203,
- E231,
- W503,
- W291,
- C901,
-
-per-file-ignores = __init__.py:F401
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index e05d708..dd55a9e 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -2,9 +2,11 @@ name: "CodeQL"
on:
push:
- branches: [ "main" ]
+ branches:
+ - "main"
pull_request:
- branches: [ "main" ]
+ branches:
+ - "*"
jobs:
analyze:
@@ -18,17 +20,17 @@ jobs:
strategy:
fail-fast: false
matrix:
- language: [ 'python' ]
+ language: [ 'go' ]
steps:
- - name: Checkout repository
- uses: actions/checkout@v3
- - name: Initialize CodeQL
- uses: github/codeql-action/init@v2
- with:
- languages: ${{ matrix.language }}
- - name: Autobuild
- uses: github/codeql-action/autobuild@v2
- - name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v2
- with:
- category: "/language:${{matrix.language}}"
+ - name: Checkout repository
+ uses: actions/checkout@v3
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v3
+ with:
+ languages: ${{ matrix.language }}
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v2
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v2
+ with:
+ category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml
deleted file mode 100644
index b2ffeab..0000000
--- a/.github/workflows/pylint.yml
+++ /dev/null
@@ -1,29 +0,0 @@
-name: Pylint
-
-on:
- push:
- branches: [ "main" ]
- pull_request:
- branches: [ "main" ]
-
-jobs:
- pylint:
- name: Pylint
- runs-on: ubuntu-latest
- strategy:
- matrix:
- python-version: ["3.12"]
- steps:
- - uses: actions/checkout@v3
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v3
- with:
- python-version: ${{ matrix.python-version }}
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install pylint
- pip install -r requirements.txt
- - name: Analysing the code with pylint
- run: |
- make pylint
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/release-image.yml
similarity index 90%
rename from .github/workflows/docker-publish.yml
rename to .github/workflows/release-image.yml
index cd4ef9a..8f5d0a1 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/release-image.yml
@@ -1,8 +1,9 @@
-name: Docker
+name: Release Image
on:
push:
- tags: [ '*' ]
+ tags:
+ - "*"
env:
REGISTRY: ghcr.io
@@ -28,8 +29,8 @@ jobs:
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
with:
registry: ${{ env.REGISTRY }}
- username: ${{ secrets.USER_NAME }}
- password: ${{ secrets.USER_TOKEN }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
@@ -45,6 +46,7 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+ platforms: linux/amd64,linux/arm64
- name: Sign the published Docker image
env:
TAGS: ${{ steps.meta.outputs.tags }}
diff --git a/.gitignore b/.gitignore
index 01dbe79..397b6bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,13 +5,6 @@
env
.env
-# Trasnlation
-*.mo
-
-# Python
-*.pyc
-*.db
-
# Log
*.log
.report
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
deleted file mode 100644
index a85569a..0000000
--- a/.pre-commit-config.yaml
+++ /dev/null
@@ -1,29 +0,0 @@
-default_stages: [commit]
-repos:
- - repo: https://github.com/asottile/pyupgrade
- rev: v3.3.1
- hooks:
- - id: pyupgrade
- - repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v4.4.0
- hooks:
- - id: check-merge-conflict
- - repo: https://github.com/psf/black
- rev: 23.3.0
- hooks:
- - id: black
- language_version: python3.12
- args:
- - "--line-length=120"
- - repo: https://github.com/pycqa/isort
- rev: 5.12.0
- hooks:
- - id: isort
- args: [ "--profile", "black" ]
- - repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v2.1.0
- hooks:
- - id: flake8
- args:
- - "--config"
- - ".flake8"
diff --git a/Dockerfile b/Dockerfile
index 9242984..d682db0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,16 +1,34 @@
-FROM python:3.12.7-slim
+# build stage
+FROM golang:1.24-alpine AS builder
-WORKDIR /usr/src/app
+# install ca-certificates for https requests
+RUN apk --no-cache add ca-certificates tzdata
-COPY requirements.txt /usr/src/app/
-RUN pip3 install -U pip && pip3 install -r requirements.txt
+# set working directory
+WORKDIR /app
-COPY . /usr/src/app
+# copy go mod files
+COPY go.mod go.sum ./
-RUN apt-get update && apt-get install -y gettext
+# download dependencies
+RUN go mod download
-RUN cp env.example .env \
- && python3 manage.py compilemessages -l zh_Hans \
- && rm -rf .env
+# copy source code
+COPY . .
-RUN mkdir -p /usr/src/app/celery-logs /usr/src/app/logs /usr/src/app/daphne-logs
+# build the application
+RUN CGO_ENABLED=0 go build -a -installsuffix cgo -o api-center .
+
+# final stage
+FROM alpine:latest
+
+# install ca-certificates for https requests
+RUN apk --no-cache add ca-certificates tzdata
+
+WORKDIR /app
+
+# copy the binary from builder stage
+COPY --from=builder /app/api-center .
+
+# copy locales directory for i18n support
+COPY --from=builder /app/locales ./locales
diff --git a/Makefile b/Makefile
index 6b0a957..c837be6 100644
--- a/Makefile
+++ b/Makefile
@@ -1,10 +1,9 @@
-pylint:
- scripts/pylint.sh
+swagger:
+ scripts/swagger.sh
-pre-commit:
- scripts/pre-commit.sh
+tidy:
+ scripts/tidy.sh
-lint: pre-commit pylint
-
-messages:
- scripts/messages.sh
+lint:
+ scripts/tidy.sh
+ scripts/swagger.sh
\ No newline at end of file
diff --git a/apps/__init__.py b/apps/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/account/__init__.py b/apps/account/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/account/admin.py b/apps/account/admin.py
deleted file mode 100644
index 4b5a3bf..0000000
--- a/apps/account/admin.py
+++ /dev/null
@@ -1,45 +0,0 @@
-from django.contrib import admin
-from django.contrib.admin import SimpleListFilter
-from django.utils import timezone
-from django.utils.translation import gettext_lazy
-from ovinc_client.core.utils import strtobool
-
-from apps.account.models import User, UserToken
-
-
-@admin.register(User)
-class UserAdmin(admin.ModelAdmin):
- list_display = ["username", "nick_name", "user_type", "date_joined", "last_login"]
- list_filter = ["user_type"]
- search_fields = ["username", "nick_name"]
-
-
-class ExpiredFilter(SimpleListFilter):
- title = gettext_lazy("Is Expired")
- parameter_name = "is_expired"
-
- def lookups(self, request, model_admin):
- return (
- (True, gettext_lazy("Yes")),
- (False, gettext_lazy("No")),
- )
-
- def queryset(self, request, queryset):
- if self.value():
- is_expired = strtobool(self.value())
- if is_expired:
- return queryset.filter(expired_at__lt=timezone.now())
- return queryset.filter(expired_at__gte=timezone.now())
- return queryset
-
-
-@admin.register(UserToken)
-class UserTokenAdmin(admin.ModelAdmin):
- list_display = ["id", "user", "session_key", "login_ip", "user_agent", "expired_at", "is_expired"]
- list_filter = ["user__username", ExpiredFilter]
- search_fields = ["session_key", "login_ip"]
- ordering = ["-id"]
-
- @admin.display(description=gettext_lazy("Is Expired"), boolean=True)
- def is_expired(self, token: UserToken) -> bool:
- return token.expired_at < timezone.now()
diff --git a/apps/account/apps.py b/apps/account/apps.py
deleted file mode 100644
index 1fee4f2..0000000
--- a/apps/account/apps.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from django.apps import AppConfig
-from django.utils.translation import gettext_lazy
-
-
-class HomeConfig(AppConfig):
- default_auto_field = "django.db.models.BigAutoField"
- name = "apps.account"
- verbose_name = gettext_lazy("User Account")
-
- def ready(self):
- # pylint: disable=C0415,W0611
- import apps.account.signals # noqa
diff --git a/apps/account/constants.py b/apps/account/constants.py
deleted file mode 100644
index f5b00d4..0000000
--- a/apps/account/constants.py
+++ /dev/null
@@ -1,41 +0,0 @@
-from django.utils.translation import gettext_lazy
-from ovinc_client.core.models import TextChoices
-
-USERNAME_REGEX = r"[a-zA-Z]{1}[a-zA-Z0-9-_]{3,}"
-
-PHONE_VERIFY_CODE_LENGTH = 6
-PHONE_VERIFY_CODE_TIMEOUT = 60 * 60 # second
-PHONE_VERIFY_CODE_KEY = "phone_verify_code:{phone_number}"
-
-LOGIN_CODE_KEY = "login_code:{code}"
-
-WECHAT_LOGIN_STATE_KEY = "wechat_login_state:{state}"
-WECHAT_USER_INFO_KEY = "wechat_user_info:{code}"
-
-
-class UserTypeChoices(TextChoices):
- """
- User Type Choices
- """
-
- PERSONAL = "personal", gettext_lazy("Personal")
- PLATFORM = "platform", gettext_lazy("Platform")
-
-
-class WeChatAuthType(TextChoices):
- """
- WeChat Auth Type
- """
-
- CODE = "authorization_code", gettext_lazy("Code")
-
-
-class PhoneNumberAreas(TextChoices):
- """
- Phone Number Areas
- """
-
- CHINA = "+86", gettext_lazy("Mainland China (+86)")
- HONG_KONG = "+852", gettext_lazy("Hong Kong, China (+852)")
- MACAO = "+853", gettext_lazy("Macao, China (+853)")
- TAIWAN = "+886", gettext_lazy("Taiwan, China (+886)")
diff --git a/apps/account/exceptions.py b/apps/account/exceptions.py
deleted file mode 100644
index 3cafd0f..0000000
--- a/apps/account/exceptions.py
+++ /dev/null
@@ -1,53 +0,0 @@
-from django.utils.translation import gettext_lazy
-from rest_framework import status
-from rest_framework.exceptions import APIException
-
-
-class SignInParamNotExist(APIException):
- status_code = status.HTTP_400_BAD_REQUEST
- default_detail = gettext_lazy("Sign In Params Not Exist")
-
-
-class UserNotExist(APIException):
- status_code = status.HTTP_404_NOT_FOUND
- default_detail = gettext_lazy("User Not Exist")
-
-
-class WrongSignInParam(APIException):
- status_code = status.HTTP_400_BAD_REQUEST
- default_detail = gettext_lazy("Wrong Username or Password")
-
-
-class TokenNotExist(APIException):
- status_code = status.HTTP_400_BAD_REQUEST
- default_detail = gettext_lazy("Token Not Exist")
-
-
-class WrongToken(APIException):
- status_code = status.HTTP_400_BAD_REQUEST
- default_detail = gettext_lazy("Wrong Token")
-
-
-class StateInvalid(APIException):
- status_code = status.HTTP_400_BAD_REQUEST
- default_detail = gettext_lazy("State Invalid")
-
-
-class WeChatLoginFailed(APIException):
- status_code = status.HTTP_400_BAD_REQUEST
- default_detail = gettext_lazy("WeChat Login Failed")
-
-
-class OriginPasswordInvalid(APIException):
- status_code = status.HTTP_400_BAD_REQUEST
- default_detail = gettext_lazy("Origin Password Invalid")
-
-
-class PhoneVerifyCodeInvalid(APIException):
- status_code = status.HTTP_400_BAD_REQUEST
- default_detail = gettext_lazy("Phone Verify Code Invalid")
-
-
-class RegistryLocked(APIException):
- status_code = status.HTTP_503_SERVICE_UNAVAILABLE
- default_detail = gettext_lazy("Registry Disabled")
diff --git a/apps/account/migrations/0001_initial.py b/apps/account/migrations/0001_initial.py
deleted file mode 100644
index 81022fa..0000000
--- a/apps/account/migrations/0001_initial.py
+++ /dev/null
@@ -1,94 +0,0 @@
-# pylint: disable=R0801,C0103,C0301
-# Generated by Django 4.2 on 2023-04-15 15:09
-
-import django.contrib.auth.models
-import django.contrib.auth.validators
-from django.db import migrations, models
-
-import apps.account.models
-
-
-class Migration(migrations.Migration):
- initial = True
-
- dependencies = [
- ("auth", "0012_alter_user_first_name_max_length"),
- ]
-
- operations = [
- migrations.CreateModel(
- name="User",
- fields=[
- ("password", models.CharField(max_length=128, verbose_name="password")),
- ("last_login", models.DateTimeField(blank=True, null=True, verbose_name="last login")),
- (
- "is_superuser",
- models.BooleanField(
- default=False,
- help_text="Designates that this user has all permissions without explicitly assigning them.",
- verbose_name="superuser status",
- ),
- ),
- ("is_deleted", models.BooleanField(db_index=True, default=False, verbose_name="Soft Delete")),
- (
- "username",
- models.CharField(
- error_messages={"unique": "already in use"},
- max_length=32,
- primary_key=True,
- serialize=False,
- validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
- verbose_name="username",
- ),
- ),
- ("nick_name", models.CharField(blank=True, max_length=32, null=True, verbose_name="Nick Name")),
- (
- "user_type",
- models.CharField(
- choices=[("personal", "Personal"), ("platform", "Platform")],
- default="personal",
- max_length=32,
- verbose_name="User Type",
- ),
- ),
- ("date_joined", models.DateTimeField(auto_now_add=True, verbose_name="Date Joined")),
- ("phone_number", models.CharField(blank=True, max_length=32, null=True, verbose_name="Phone Number")),
- (
- "email_address",
- models.EmailField(blank=True, max_length=254, null=True, verbose_name="Email Address"),
- ),
- ("wechat_id", models.CharField(blank=True, max_length=255, null=True, verbose_name="Wechat ID")),
- (
- "groups",
- models.ManyToManyField(
- blank=True,
- help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
- related_name="user_set",
- related_query_name="user",
- to="auth.group",
- verbose_name="groups",
- ),
- ),
- (
- "user_permissions",
- models.ManyToManyField(
- blank=True,
- help_text="Specific permissions for this user.",
- related_name="user_set",
- related_query_name="user",
- to="auth.permission",
- verbose_name="user permissions",
- ),
- ),
- ],
- options={
- "verbose_name": "User",
- "verbose_name_plural": "User",
- "ordering": ["username"],
- },
- managers=[
- ("objects", apps.account.models.UserManager()),
- ("_objects", django.contrib.auth.models.UserManager()),
- ],
- ),
- ]
diff --git a/apps/account/migrations/0002_remove_user_wechat_id_user_wechat_open_id_and_more.py b/apps/account/migrations/0002_remove_user_wechat_id_user_wechat_open_id_and_more.py
deleted file mode 100644
index 71539e8..0000000
--- a/apps/account/migrations/0002_remove_user_wechat_id_user_wechat_open_id_and_more.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# pylint: disable=R0801,C0103
-# Generated by Django 4.2 on 2023-04-21 09:38
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
- dependencies = [
- ("account", "0001_initial"),
- ]
-
- operations = [
- migrations.RemoveField(
- model_name="user",
- name="wechat_id",
- ),
- migrations.AddField(
- model_name="user",
- name="wechat_open_id",
- field=models.CharField(blank=True, max_length=255, null=True, verbose_name="Wechat Open ID"),
- ),
- migrations.AddField(
- model_name="user",
- name="wechat_union_id",
- field=models.CharField(blank=True, max_length=255, null=True, verbose_name="Wechat Union ID"),
- ),
- ]
diff --git a/apps/account/migrations/0003_user_is_staff.py b/apps/account/migrations/0003_user_is_staff.py
deleted file mode 100644
index 01ce65c..0000000
--- a/apps/account/migrations/0003_user_is_staff.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# pylint: disable=R0801,C0103
-# Generated by Django 4.2.3 on 2023-09-11 12:02
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
- dependencies = [
- ("account", "0002_remove_user_wechat_id_user_wechat_open_id_and_more"),
- ]
-
- operations = [
- migrations.AddField(
- model_name="user",
- name="is_staff",
- field=models.BooleanField(default=False, verbose_name="Is Staff"),
- ),
- ]
diff --git a/apps/account/migrations/0004_user_avatar_alter_user_email_address_and_more.py b/apps/account/migrations/0004_user_avatar_alter_user_email_address_and_more.py
deleted file mode 100644
index 6b70147..0000000
--- a/apps/account/migrations/0004_user_avatar_alter_user_email_address_and_more.py
+++ /dev/null
@@ -1,38 +0,0 @@
-# pylint: disable=R0801,C0103
-# Generated by Django 4.2.3 on 2023-09-15 11:45
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
- dependencies = [
- ("account", "0003_user_is_staff"),
- ]
-
- operations = [
- migrations.AddField(
- model_name="user",
- name="avatar",
- field=models.TextField(blank=True, null=True, verbose_name="Avatar"),
- ),
- migrations.AlterField(
- model_name="user",
- name="email_address",
- field=models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name="Email Address"),
- ),
- migrations.AlterField(
- model_name="user",
- name="phone_number",
- field=models.CharField(blank=True, max_length=32, null=True, unique=True, verbose_name="Phone Number"),
- ),
- migrations.AlterField(
- model_name="user",
- name="wechat_open_id",
- field=models.CharField(blank=True, max_length=255, null=True, unique=True, verbose_name="Wechat Open ID"),
- ),
- migrations.AlterField(
- model_name="user",
- name="wechat_union_id",
- field=models.CharField(blank=True, max_length=255, null=True, unique=True, verbose_name="Wechat Union ID"),
- ),
- ]
diff --git a/apps/account/migrations/0005_usertoken.py b/apps/account/migrations/0005_usertoken.py
deleted file mode 100644
index 14fffd3..0000000
--- a/apps/account/migrations/0005_usertoken.py
+++ /dev/null
@@ -1,41 +0,0 @@
-# pylint: disable=R0801,C0103
-# Generated by Django 4.2.13 on 2024-07-26 07:46
-
-import django.db.models.deletion
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
- dependencies = [
- ("account", "0004_user_avatar_alter_user_email_address_and_more"),
- ]
-
- operations = [
- migrations.CreateModel(
- name="UserToken",
- fields=[
- ("id", models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID")),
- ("session_key", models.CharField(db_index=True, max_length=255, verbose_name="Session Key")),
- ("cache_key", models.CharField(db_index=True, max_length=255, verbose_name="Cache Key")),
- ("login_ip", models.CharField(db_index=True, max_length=64, verbose_name="Login IP")),
- ("user_agent", models.TextField(blank=True, null=True, verbose_name="User Agent")),
- ("expired_at", models.DateTimeField(db_index=True, verbose_name="Expired at")),
- (
- "user",
- models.ForeignKey(
- db_constraint=False,
- on_delete=django.db.models.deletion.PROTECT,
- to=settings.AUTH_USER_MODEL,
- verbose_name="User",
- ),
- ),
- ],
- options={
- "verbose_name": "User Token",
- "verbose_name_plural": "User Token",
- "ordering": ["-id"],
- "index_together": {("user", "expired_at")},
- },
- ),
- ]
diff --git a/apps/account/migrations/0006_alter_usertoken_index_together.py b/apps/account/migrations/0006_alter_usertoken_index_together.py
deleted file mode 100644
index ae65e33..0000000
--- a/apps/account/migrations/0006_alter_usertoken_index_together.py
+++ /dev/null
@@ -1,17 +0,0 @@
-# pylint: disable=R0801,C0103
-# Generated by Django 4.2.13 on 2024-07-26 09:10
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
- dependencies = [
- ("account", "0005_usertoken"),
- ]
-
- operations = [
- migrations.AlterIndexTogether(
- name="usertoken",
- index_together={("user", "session_key", "expired_at"), ("user", "expired_at")},
- ),
- ]
diff --git a/apps/account/migrations/0007_fake_email.py b/apps/account/migrations/0007_fake_email.py
deleted file mode 100644
index 347fb7a..0000000
--- a/apps/account/migrations/0007_fake_email.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# pylint: disable=R0801,C0103
-# Generated by Django 4.2.13 on 2024-07-26 09:10
-from urllib.parse import urlparse
-
-from django.conf import settings
-from django.db import migrations
-from django.db.models import F, Value
-from django.db.models.functions import Concat
-
-
-def fake_email(*args, **kwargs):
- from apps.account.models import User # pylint: disable=C0415
-
- url = urlparse(settings.BACKEND_URL)
- User.objects.filter(email_address__isnull=True).update(
- email_address=Concat(F("username"), Value(f"@{url.hostname}"))
- )
-
-
-class Migration(migrations.Migration):
- dependencies = [
- ("account", "0006_alter_usertoken_index_together"),
- ]
-
- operations = [
- migrations.RunPython(fake_email),
- ]
diff --git a/apps/account/migrations/__init__.py b/apps/account/migrations/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/account/models.py b/apps/account/models.py
deleted file mode 100644
index 54da7f4..0000000
--- a/apps/account/models.py
+++ /dev/null
@@ -1,219 +0,0 @@
-import abc
-import hashlib
-from typing import Union
-
-from django.conf import settings
-from django.contrib.auth.base_user import AbstractBaseUser
-from django.contrib.auth.hashers import make_password
-from django.contrib.auth.models import AbstractUser, AnonymousUser, PermissionsMixin
-from django.contrib.auth.models import UserManager as _UserManager
-from django.core.cache import cache
-from django.db import models
-from django.utils import timezone
-from django.utils.translation import gettext, gettext_lazy
-from django_redis.client import DefaultClient
-from ovinc_client.core.constants import (
- MAX_CHAR_LENGTH,
- MEDIUM_CHAR_LENGTH,
- SHORT_CHAR_LENGTH,
-)
-from ovinc_client.core.models import SoftDeletedManager, SoftDeletedModel
-from ovinc_client.core.utils import num_code, uniq_id
-
-from apps.account.constants import (
- LOGIN_CODE_KEY,
- PHONE_VERIFY_CODE_KEY,
- PHONE_VERIFY_CODE_LENGTH,
- PHONE_VERIFY_CODE_TIMEOUT,
- PhoneNumberAreas,
- UserTypeChoices,
-)
-from apps.cel.tasks import send_notice
-from apps.notice.constants import NoticeWayChoices
-
-cache: DefaultClient
-
-
-class UserManager(SoftDeletedManager, _UserManager):
- """
- User Manager
- """
-
- # pylint: disable=W0237
- def create_user(self, username, nick_name=None, password=None, **extra_fields):
- if not username:
- raise ValueError(gettext("Username Cannot be Empty"))
- user = self.model(username=username, nick_name=nick_name, **extra_fields)
- user.password = make_password(password)
- user.save(using=self._db)
- return user
-
- # pylint: disable=W0237
- def create_superuser(self, username, nick_name=None, password=None, **extra_fields):
- extra_fields["is_superuser"] = True
- self.create_user(username, nick_name, password, **extra_fields)
-
-
-class User(SoftDeletedModel, AbstractBaseUser, PermissionsMixin):
- """
- User
- """
-
- username = models.CharField(
- gettext_lazy("username"),
- max_length=SHORT_CHAR_LENGTH,
- primary_key=True,
- validators=[AbstractUser.username_validator],
- error_messages={"unique": gettext_lazy("already in use")},
- )
- nick_name = models.CharField(gettext_lazy("Nick Name"), max_length=SHORT_CHAR_LENGTH, blank=True, null=True)
- user_type = models.CharField(
- gettext_lazy("User Type"),
- choices=UserTypeChoices.choices,
- max_length=SHORT_CHAR_LENGTH,
- default=UserTypeChoices.PERSONAL.value,
- )
- date_joined = models.DateTimeField(gettext_lazy("Date Joined"), auto_now_add=True)
- phone_number = models.CharField(
- gettext_lazy("Phone Number"), max_length=SHORT_CHAR_LENGTH, null=True, blank=True, unique=True
- )
- email_address = models.EmailField(gettext_lazy("Email Address"), null=True, blank=True, unique=True)
- wechat_open_id = models.CharField(
- gettext_lazy("Wechat Open ID"), max_length=MAX_CHAR_LENGTH, null=True, blank=True, unique=True
- )
- wechat_union_id = models.CharField(
- gettext_lazy("Wechat Union ID"), max_length=MAX_CHAR_LENGTH, null=True, blank=True, unique=True
- )
- avatar = models.TextField(gettext_lazy("Avatar"), null=True, blank=True)
- is_staff = models.BooleanField(gettext_lazy("Is Staff"), default=False)
-
- USERNAME_FIELD = "username"
- objects = UserManager()
- _objects = _UserManager()
-
- class Meta:
- verbose_name = gettext_lazy("User")
- verbose_name_plural = verbose_name
- ordering = ["username"]
-
- def generate_oauth_code(self) -> str:
- """
- Generate OAuth User Code
- """
-
- code = uniq_id()
- cache_key = LOGIN_CODE_KEY.format(code=code)
- cache.set(cache_key, self.username, timeout=settings.OAUTH_CODE_TIMEOUT)
- return code
-
- @classmethod
- def check_oauth_code(cls, code: str) -> (bool, Union[models.Model, None]):
- """
- Check OAuth User Code
- """
-
- cache_key = LOGIN_CODE_KEY.format(code=code)
- username = cache.get(cache_key)
- cache.delete(cache_key)
- try:
- return True, cls.objects.get(username=username)
- except cls.DoesNotExist: # pylint: disable=E1101
- return False, None
-
- def reset_password(self, password: str, is_raw: bool = False) -> None:
- """
- Reset User Password
- """
-
- if is_raw:
- # pylint: disable=E1101
- password = hashlib.sha256(f"{password}{self.username.lower()}".encode()).hexdigest()
-
- self.set_password(password)
- self.save(update_fields=["password"])
-
- @classmethod
- def send_phone_verify_code(cls, area: str, phone_number: str) -> None:
- """
- Send Phone Verification Code
- """
-
- phone_number = f"{area}{phone_number}"
- key = PHONE_VERIFY_CODE_KEY.format(phone_number=phone_number)
- code = num_code(PHONE_VERIFY_CODE_LENGTH)
- cache.set(key=key, value=code, timeout=PHONE_VERIFY_CODE_TIMEOUT)
- send_notice.delay(
- notice_type=NoticeWayChoices.MSG,
- usernames=[],
- receivers=[phone_number],
- content={"tid": cls.get_verify_code_tid(area), "params": [code, str(PHONE_VERIFY_CODE_TIMEOUT // 60)]},
- )
-
- @classmethod
- def get_verify_code_tid(cls, area: str) -> str:
- if area == PhoneNumberAreas.CHINA:
- return settings.NOTICE_SMS_ID_VERIFY_CODE
- return settings.NOTICE_SMS_ID_VERIFY_CODE_GLOBAL
-
- @classmethod
- def check_phone_verify_code(cls, area: str, phone_number: str, code: str) -> bool:
- """
- Verify Phone Number
- """
-
- phone_number = f"{area}{phone_number}"
- key = PHONE_VERIFY_CODE_KEY.format(phone_number=phone_number)
- val = cache.get(key=key)
- result = str(val) == str(code)
- if result:
- cache.delete(key=key)
- return result
-
- @classmethod
- def load_user_by_union_id(cls, union_id: str) -> "User":
- return cls.objects.filter(wechat_union_id=union_id).first()
-
- def logout_all(self) -> None:
- """
- Logout all token
- """
-
- # pylint: disable=E1101
- tokens = UserToken.objects.filter(user=self, expired_at__gte=timezone.now())
- for token in tokens:
- cache.delete(token.cache_key)
- tokens.update(expired_at=timezone.now())
-
-
-class CustomAnonymousUser(AnonymousUser, abc.ABC):
- """
- Anonymous User
- """
-
- nick_name = "AnonymousUser"
- user_type = UserTypeChoices.PLATFORM.value
-
-
-class UserToken(models.Model):
- """
- User Token
- """
-
- id = models.BigAutoField(gettext_lazy("ID"), primary_key=True)
- user = models.ForeignKey(
- verbose_name=gettext_lazy("User"), to="User", on_delete=models.PROTECT, db_constraint=False
- )
- session_key = models.CharField(gettext_lazy("Session Key"), max_length=MAX_CHAR_LENGTH, db_index=True)
- cache_key = models.CharField(gettext_lazy("Cache Key"), max_length=MAX_CHAR_LENGTH, db_index=True)
- login_ip = models.CharField(gettext_lazy("Login IP"), max_length=MEDIUM_CHAR_LENGTH, db_index=True)
- user_agent = models.TextField(gettext_lazy("User Agent"), null=True, blank=True)
- expired_at = models.DateTimeField(gettext_lazy("Expired at"), db_index=True)
-
- class Meta:
- verbose_name = gettext_lazy("User Token")
- verbose_name_plural = verbose_name
- ordering = ["-id"]
- index_together = [
- ["user", "expired_at"],
- ["user", "session_key", "expired_at"],
- ]
diff --git a/apps/account/rates.py b/apps/account/rates.py
deleted file mode 100644
index b0c4b36..0000000
--- a/apps/account/rates.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from ovinc_client.core.utils import get_ip
-from rest_framework.throttling import SimpleRateThrottle
-
-
-class SMSRateThrottle(SimpleRateThrottle):
- """
- SMS Throttle
- """
-
- scope = "phone_verify_code"
-
- def get_cache_key(self, request, view) -> str:
- return f'rate:{self.scope}:{request.data.get("phone_area")}{request.data.get("phone_number")}'
-
-
-class IPRateThrottle(SimpleRateThrottle):
- """
- IP Throttle
- """
-
- scope = "ip"
-
- def get_cache_key(self, request, view) -> str:
- return f"rate:{self.scope}:{get_ip(request)}"
diff --git a/apps/account/serializers.py b/apps/account/serializers.py
deleted file mode 100644
index d89e326..0000000
--- a/apps/account/serializers.py
+++ /dev/null
@@ -1,171 +0,0 @@
-import re
-
-from django.contrib.auth import get_user_model
-from django.utils.translation import gettext, gettext_lazy
-from ovinc_client.tcaptcha.exceptions import TCaptchaInvalid
-from ovinc_client.tcaptcha.utils import TCaptchaVerify
-from rest_framework import serializers
-
-from apps.account.constants import (
- PHONE_VERIFY_CODE_LENGTH,
- USERNAME_REGEX,
- PhoneNumberAreas,
-)
-from apps.account.exceptions import PhoneVerifyCodeInvalid
-from apps.account.models import User
-from apps.home.constants import BuildInKeys
-from apps.home.models import MetaConfig
-
-USER_MODEL: User = get_user_model()
-
-
-class UserInfoSerializer(serializers.ModelSerializer):
- """
- User Info
- """
-
- class Meta:
- model = USER_MODEL
- fields = ["username", "nick_name", "last_login", "user_type"]
-
-
-class SignInSerializer(serializers.Serializer):
- """
- Sign in
- """
-
- username = serializers.CharField(label=gettext_lazy("Username"))
- password = serializers.CharField(label=gettext_lazy("Password"))
- is_oauth = serializers.BooleanField(label=gettext_lazy("Is OAuth"), default=False)
- wechat_code = serializers.CharField(label=gettext_lazy("WeChat Code"), required=False)
- tcaptcha = serializers.JSONField(label=gettext_lazy("Tencent Captcha"), required=False, default=dict)
-
- def validate(self, attrs: dict) -> dict:
- data = super().validate(attrs)
- if not TCaptchaVerify(user_ip=self.context.get("user_ip", ""), **data.get("tcaptcha", {})).verify():
- raise TCaptchaInvalid()
- return data
-
-
-class UserRegistrySerializer(serializers.ModelSerializer):
- """
- User Registry
- """
-
- username = serializers.RegexField(label=gettext_lazy("Username"), regex=USERNAME_REGEX)
- is_oauth = serializers.BooleanField(label=gettext_lazy("Is OAuth"), default=False)
- wechat_code = serializers.CharField(label=gettext_lazy("WeChat Code"), required=False)
- phone_area = serializers.ChoiceField(label=gettext_lazy("Phone Area"), choices=PhoneNumberAreas.choices)
- phone_number = serializers.CharField(label=gettext_lazy("Phone Number"))
- phone_verify = serializers.CharField(
- label=gettext_lazy("Phone Verify Code"),
- min_length=PHONE_VERIFY_CODE_LENGTH,
- max_length=PHONE_VERIFY_CODE_LENGTH,
- )
- tcaptcha = serializers.JSONField(label=gettext_lazy("Tencent Captcha"), required=False, default=dict)
-
- class Meta:
- model = USER_MODEL
- fields = [
- "username",
- "nick_name",
- "password",
- "is_oauth",
- "wechat_code",
- "phone_area",
- "phone_number",
- "phone_verify",
- "tcaptcha",
- ]
-
- def validate(self, attrs: dict) -> dict:
- data = super().validate(attrs)
- if not TCaptchaVerify(user_ip=self.context.get("user_ip", ""), **data.get("tcaptcha", {})).verify():
- raise TCaptchaInvalid()
- if not USER_MODEL.check_phone_verify_code(
- area=data["phone_area"], phone_number=data["phone_number"], code=data["phone_verify"]
- ):
- raise PhoneVerifyCodeInvalid()
- return data
-
- def validate_username(self, username: str) -> str:
- username_extra_regex = MetaConfig.objects.filter(key=BuildInKeys.USERNAME_EXTRA_REGEX[0]).first()
- if not username_extra_regex:
- return username
- if re.compile(username_extra_regex.val).match(username):
- return username
- raise serializers.ValidationError(gettext("Username Invalid"))
-
- def validate_phone_number(self, phone_number: str) -> str:
- if USER_MODEL.objects.filter(phone_number=phone_number).exists():
- raise serializers.ValidationError(gettext("Phone Number Already Exists"))
- return phone_number
-
-
-class VerifyCodeRequestSerializer(serializers.Serializer):
- """
- Verify Code
- """
-
- code = serializers.CharField(label=gettext_lazy("Code"))
-
-
-class WeChatLoginReqSerializer(serializers.Serializer):
- """
- WeChat Login
- """
-
- code = serializers.CharField(label=gettext_lazy("Code"))
- state = serializers.CharField(label=gettext_lazy("State"))
- is_oauth = serializers.BooleanField(label=gettext_lazy("Is OAuth"), default=False)
-
-
-class ResetPasswordRequestSerializer(serializers.Serializer):
- """
- Reset Password
- """
-
- username = serializers.RegexField(label=gettext_lazy("Username"), regex=USERNAME_REGEX)
- password = serializers.CharField(label=gettext_lazy("Password"), max_length=128)
- phone_area = serializers.ChoiceField(label=gettext_lazy("Phone Area"), choices=PhoneNumberAreas.choices)
- phone_number = serializers.CharField(label=gettext_lazy("Phone Number"))
- phone_verify = serializers.CharField(
- label=gettext_lazy("Phone Verify Code"),
- min_length=PHONE_VERIFY_CODE_LENGTH,
- max_length=PHONE_VERIFY_CODE_LENGTH,
- )
- tcaptcha = serializers.JSONField(label=gettext_lazy("Tencent Captcha"), required=False, default=dict)
-
- def validate(self, attrs: dict) -> dict:
- data = super().validate(attrs)
- if not TCaptchaVerify(user_ip=self.context.get("user_ip", ""), **data.get("tcaptcha", {})).verify():
- raise TCaptchaInvalid()
- if not USER_MODEL.check_phone_verify_code(
- area=data["phone_area"], phone_number=data["phone_number"], code=data["phone_verify"]
- ):
- raise PhoneVerifyCodeInvalid()
- return data
-
-
-class SendVerifyCodeRequestSerializer(serializers.Serializer):
- """
- Verify Code
- """
-
- phone_area = serializers.ChoiceField(label=gettext_lazy("Phone Area"), choices=PhoneNumberAreas.choices)
- phone_number = serializers.CharField(label=gettext_lazy("Phone Number"))
- tcaptcha = serializers.JSONField(label=gettext_lazy("Tencent Captcha"), required=False, default=dict)
-
- def validate(self, attrs: dict) -> dict:
- data = super().validate(attrs)
- if not TCaptchaVerify(user_ip=self.context.get("user_ip", ""), **data.get("tcaptcha", {})).verify():
- raise TCaptchaInvalid()
- return data
-
-
-class OIDCLoginRequestSerializer(serializers.Serializer):
- """
- OIDC Login
- """
-
- next = serializers.CharField(label=gettext_lazy("Next"))
diff --git a/apps/account/signals.py b/apps/account/signals.py
deleted file mode 100644
index 151a10e..0000000
--- a/apps/account/signals.py
+++ /dev/null
@@ -1,32 +0,0 @@
-import datetime
-
-from django.conf import settings
-from django.contrib.auth.signals import user_logged_in, user_logged_out
-from django.dispatch import receiver
-from django.utils import timezone
-from ovinc_client.core.utils import get_ip
-
-from apps.account.models import UserToken
-
-
-@receiver(user_logged_in)
-def user_logged_in_handler(sender, request, user, **kwargs):
- # pylint: disable=E1101
- UserToken.objects.create(
- user=user,
- session_key=request.session.session_key,
- cache_key=request.session.cache_key,
- login_ip=get_ip(request),
- user_agent=request.headers.get("User-Agent"),
- expired_at=timezone.now() + datetime.timedelta(seconds=settings.SESSION_COOKIE_AGE),
- )
-
-
-@receiver(user_logged_out)
-def user_logged_out_handler(sender, request, user, **kwargs):
- # pylint: disable=E1101
- (
- UserToken.objects.filter(user=user)
- .filter(session_key=request.session.session_key)
- .filter(expired_at__gte=timezone.now())
- ).update(expired_at=timezone.now())
diff --git a/apps/account/urls.py b/apps/account/urls.py
deleted file mode 100644
index c59d1af..0000000
--- a/apps/account/urls.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from rest_framework.routers import DefaultRouter
-
-from apps.account.views import UserInfoViewSet, UserSignViewSet
-
-router = DefaultRouter()
-router.register("", UserSignViewSet)
-router.register("user_info", UserInfoViewSet, basename="user_info")
-
-urlpatterns = router.urls
diff --git a/apps/account/utils.py b/apps/account/utils.py
deleted file mode 100644
index f2714a4..0000000
--- a/apps/account/utils.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from rest_framework.settings import api_settings
-
-
-def userinfo(claims, user):
- claims.update(
- {
- "name": user.username,
- "nickname": user.nick_name,
- "preferred_username": user.username,
- "updated_at": user.last_login.strftime(api_settings.DATETIME_FORMAT),
- "email": user.email_address,
- }
- )
- return claims
-
-
-def default_sub_generator(user):
- return f"{user.username}"
diff --git a/apps/account/views.py b/apps/account/views.py
deleted file mode 100644
index b5c62b7..0000000
--- a/apps/account/views.py
+++ /dev/null
@@ -1,357 +0,0 @@
-import datetime
-import json
-from json import JSONDecodeError
-from urllib.parse import quote, urlparse
-
-import httpx
-from django.conf import settings
-from django.contrib import auth
-from django.contrib.auth import get_user_model
-from django.core.cache import cache
-from django.http import HttpResponseRedirect
-from ovinc_client.core.auth import SessionAuthenticate
-from ovinc_client.core.logger import logger
-from ovinc_client.core.utils import get_ip, uniq_id
-from ovinc_client.core.viewsets import MainViewSet
-from rest_framework.decorators import action
-from rest_framework.response import Response
-
-from apps.account.constants import (
- WECHAT_LOGIN_STATE_KEY,
- WECHAT_USER_INFO_KEY,
- PhoneNumberAreas,
- WeChatAuthType,
-)
-from apps.account.exceptions import (
- RegistryLocked,
- StateInvalid,
- UserNotExist,
- WeChatLoginFailed,
- WrongSignInParam,
- WrongToken,
-)
-from apps.account.models import User
-from apps.account.rates import IPRateThrottle, SMSRateThrottle
-from apps.account.serializers import (
- OIDCLoginRequestSerializer,
- ResetPasswordRequestSerializer,
- SendVerifyCodeRequestSerializer,
- SignInSerializer,
- UserInfoSerializer,
- UserRegistrySerializer,
- VerifyCodeRequestSerializer,
- WeChatLoginReqSerializer,
-)
-from apps.home.constants import BuildInKeys
-from apps.home.models import MetaConfig
-from core.auth import ApplicationAuthenticate
-from core.utils import is_wechat
-
-USER_MODEL: User = get_user_model()
-
-
-class UserInfoViewSet(MainViewSet):
- """
- User Info
- """
-
- queryset = USER_MODEL.get_queryset()
- serializer_class = UserInfoSerializer
-
- def list(self, request, *args, **kwargs):
- """
- Get User Info
- """
-
- serializer = self.serializer_class(instance=request.user)
- return Response(serializer.data)
-
-
-class UserSignViewSet(MainViewSet):
- """
- User Login and Logout
- """
-
- queryset = USER_MODEL.get_queryset()
-
- @action(methods=["POST"], detail=False, authentication_classes=[SessionAuthenticate])
- def sign_in(self, request, *args, **kwargs):
- """
- Sign in
- """
-
- # Validate Request Data
- request_serializer = SignInSerializer(data=request.data, context={"user_ip": get_ip(request)})
- request_serializer.is_valid(raise_exception=True)
- request_data = request_serializer.validated_data
-
- # login
- user: User = auth.authenticate(request, **request_data)
- if not user:
- raise WrongSignInParam()
-
- # bind wechat
- if request_data.get("wechat_code"):
- self.update_user_by_wechat(user, request_data["wechat_code"])
-
- # auth session
- auth.login(request, user)
-
- # oauth
- if request_data["is_oauth"]:
- return Response({"code": user.generate_oauth_code()})
-
- return Response()
-
- @action(methods=["GET"], detail=False)
- def sign_out(self, request, *args, **kwargs):
- """
- Sign out
- """
-
- auth.logout(request)
- return Response()
-
- @action(methods=["POST"], detail=False, authentication_classes=[SessionAuthenticate])
- def sign_up(self, request, *args, **kwargs):
- """
- sign up
- """
-
- # check registry locked
- registry_lock_config: MetaConfig | None = MetaConfig.objects.filter(key=BuildInKeys.REGISTRY_LOCKED[0]).first()
- if registry_lock_config and registry_lock_config.val:
- raise RegistryLocked()
-
- # validate request
- request_serializer = UserRegistrySerializer(data=request.data, context={"user_ip": get_ip(request)})
- request_serializer.is_valid(raise_exception=True)
- request_data = request_serializer.validated_data
-
- # save
- user = USER_MODEL.objects.create_user(
- last_login=datetime.datetime.now(),
- username=request_data["username"],
- password=request_data["password"],
- nick_name=request_data["nick_name"],
- phone_number=request_data["phone_number"],
- email_address=f"{request_data["username"]}@{urlparse(settings.BACKEND_URL).hostname}",
- )
-
- # bind wechat
- if request_data.get("wechat_code"):
- self.update_user_by_wechat(user, request_data["wechat_code"])
-
- # login session
- auth.login(request, user)
-
- # oauth
- if request_data["is_oauth"]:
- return Response({"code": user.generate_oauth_code()})
-
- # response
- return Response()
-
- @action(
- methods=["POST"],
- detail=False,
- authentication_classes=[SessionAuthenticate],
- throttle_classes=[SMSRateThrottle, IPRateThrottle],
- )
- def phone_verify_code(self, request, *args, **kwargs):
- """
- send verify code
- """
-
- # validate request
- request_serializer = SendVerifyCodeRequestSerializer(data=request.data, context={"user_ip": get_ip(request)})
- request_serializer.is_valid(raise_exception=True)
- request_data = request_serializer.validated_data
-
- # send
- USER_MODEL.send_phone_verify_code(area=request_data["phone_area"], phone_number=request_data["phone_number"])
-
- # response
- return Response()
-
- @action(methods=["GET"], detail=False)
- def oauth_code(self, request, *args, **kwargs):
- """
- oauth code
- """
-
- return Response({"code": request.user.generate_oauth_code()})
-
- @action(methods=["POST"], detail=False, authentication_classes=[ApplicationAuthenticate])
- def verify_code(self, request, *args, **kwargs):
- """
- verify oauth code
- """
-
- # validate request
- request_serializer = VerifyCodeRequestSerializer(data=request.data)
- request_serializer.is_valid(raise_exception=True)
- request_data = request_serializer.validated_data
-
- # load user
- is_success, user = USER_MODEL.check_oauth_code(request_data["code"])
- if is_success:
- return Response(UserInfoSerializer(instance=user).data)
- raise WrongToken()
-
- @action(methods=["GET"], detail=False, authentication_classes=[SessionAuthenticate])
- def wechat_config(self, request, *args, **kwargs):
- """
- WeChat Config
- """
-
- state = uniq_id()
- cache_key = WECHAT_LOGIN_STATE_KEY.format(state=state)
- cache.set(cache_key, True, timeout=settings.WECHAT_SCOPE_TIMEOUT)
-
- is_wechat_ua = is_wechat(request)
- return Response(
- {
- "app_id": settings.MP_WECHAT_APP_ID if is_wechat_ua else settings.WECHAT_APP_ID,
- "state": state,
- "is_wechat": is_wechat_ua,
- }
- )
-
- # pylint: disable=R0914
- @action(methods=["POST"], detail=False, authentication_classes=[SessionAuthenticate])
- def wechat_login(self, request, *args, **kwargs):
- """
- WeChat Login
- """
-
- # validate request
- request_serializer = WeChatLoginReqSerializer(data=request.data)
- request_serializer.is_valid(raise_exception=True)
- request_data = request_serializer.validated_data
-
- # check state
- cache_key = WECHAT_LOGIN_STATE_KEY.format(state=request_data["state"])
- if not cache.get(cache_key):
- raise StateInvalid()
- cache.delete(cache_key)
-
- # load app key app secret
- if is_wechat(request):
- app_id = settings.MP_WECHAT_APP_ID
- app_key = settings.MP_WECHAT_APP_KEY
- else:
- app_id = settings.WECHAT_APP_ID
- app_key = settings.WECHAT_APP_KEY
-
- # load access token
- url = (
- f"{settings.WECHAT_OAUTH_API}/access_token"
- f"?appid={app_id}"
- f"&secret={app_key}"
- f"&code={request_data['code']}"
- f"&grant_type={WeChatAuthType.CODE}"
- )
- client = httpx.Client()
- try:
- resp = client.get(url)
- access_info = resp.json()
- except Exception as err:
- logger.exception("[CallWeChatAPIFailed] %s %s", url, err)
- raise WeChatLoginFailed() from err
- finally:
- client.close()
-
- if "openid" not in access_info:
- logger.error("[WeChatLoginFailed] %s", access_info)
- raise WeChatLoginFailed()
-
- # get user info
- url = (
- f"{settings.WECHAT_USER_INFO_API}?access_token={access_info['access_token']}&openid={access_info['openid']}"
- )
- client = httpx.Client()
- try:
- resp = client.get(url)
- user_info = resp.json()
- except Exception as err:
- logger.exception("[CallWeChatAPIFailed] %s", url, err)
- raise WeChatLoginFailed() from err
- finally:
- client.close()
-
- code = uniq_id()
- cache_key = WECHAT_USER_INFO_KEY.format(code=code)
- cache.set(cache_key, json.dumps(user_info, ensure_ascii=False), timeout=settings.WECHAT_SCOPE_TIMEOUT)
-
- # load user
- user: User = USER_MODEL.load_user_by_union_id(union_id=user_info["unionid"])
- if user:
- self.update_user_by_wechat(user, code)
- auth.login(request, user)
- return Response({"code": user.generate_oauth_code() if request_data["is_oauth"] else ""})
-
- # need registry
- return Response({"wechat_code": code})
-
- def update_user_by_wechat(self, user: User, wechat_code: str) -> None:
- """
- Update User Info By WeChat
- """
-
- cache_key = WECHAT_USER_INFO_KEY.format(code=wechat_code)
- try:
- user_info = json.loads(cache.get(cache_key, default="{}"))
- except JSONDecodeError:
- return
-
- cache.delete(cache_key)
-
- user.wechat_union_id = user_info["unionid"]
- user.wechat_open_id = user_info["openid"]
- user.avatar = user_info["headimgurl"]
- user.save(update_fields=["wechat_union_id", "wechat_open_id", "avatar"])
-
- @action(methods=["POST"], detail=False, authentication_classes=[SessionAuthenticate])
- def reset_password(self, request, *args, **kwargs) -> Response:
- """
- Reset Password
- """
-
- # validate request
- request_serializer = ResetPasswordRequestSerializer(data=request.data, context={"user_ip": get_ip(request)})
- request_serializer.is_valid(raise_exception=True)
- request_data = request_serializer.validated_data
-
- # load user
- try:
- user: User = USER_MODEL.objects.get(
- username=request_data["username"], phone_number=request_data["phone_number"]
- )
- except USER_MODEL.DoesNotExist as err:
- raise UserNotExist() from err
-
- # set new password
- user.reset_password(request_data["password"])
-
- return Response()
-
- @action(methods=["GET"], detail=False, authentication_classes=[SessionAuthenticate])
- def phone_areas(self, request, *args, **kwargs) -> Response:
- """
- Phone Number Areas
- """
-
- return Response(data=[{"value": value, "label": str(label)} for value, label in PhoneNumberAreas.choices])
-
- @action(methods=["GET"], detail=False, authentication_classes=[SessionAuthenticate])
- def oidc_login(self, request, *args, **kwargs) -> HttpResponseRedirect:
- """
- OIDC Login, Redirect to Login Page
- """
-
- req_slz = OIDCLoginRequestSerializer(data=request.query_params)
- req_slz.is_valid(raise_exception=True)
- req_data = req_slz.validated_data
- next_url = quote(settings.BACKEND_URL + req_data["next"])
- return HttpResponseRedirect(redirect_to=f"{settings.FRONTEND_URL}/login/?next={next_url}")
diff --git a/apps/application/__init__.py b/apps/application/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/application/admin.py b/apps/application/admin.py
deleted file mode 100644
index 4d0d127..0000000
--- a/apps/application/admin.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from django.contrib import admin
-
-from apps.application.models import Application
-
-
-@admin.register(Application)
-class ApplicationAdmin(admin.ModelAdmin):
- list_display = ["app_code", "app_name", "is_deleted"]
- list_filter = ["is_deleted"]
- search_fields = ["app_code", "app_name"]
diff --git a/apps/application/apps.py b/apps/application/apps.py
deleted file mode 100644
index 7d96bc4..0000000
--- a/apps/application/apps.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from django.apps import AppConfig
-from django.utils.translation import gettext_lazy
-
-
-class HomeConfig(AppConfig):
- default_auto_field = "django.db.models.BigAutoField"
- name = "apps.application"
- verbose_name = gettext_lazy("Application")
diff --git a/apps/application/constants.py b/apps/application/constants.py
deleted file mode 100644
index f4fa11c..0000000
--- a/apps/application/constants.py
+++ /dev/null
@@ -1 +0,0 @@
-APP_CODE_REGEXP = r"^[a-zA-Z0-9-_]+$"
diff --git a/apps/application/migrations/0001_initial.py b/apps/application/migrations/0001_initial.py
deleted file mode 100644
index 5a18be8..0000000
--- a/apps/application/migrations/0001_initial.py
+++ /dev/null
@@ -1,77 +0,0 @@
-# pylint: disable=R0801,C0103
-# Generated by Django 4.2 on 2023-04-15 07:51
-
-import django.db.models.deletion
-import ovinc_client.core.models
-import ovinc_client.core.utils
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
- initial = True
-
- dependencies = [
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ]
-
- operations = [
- migrations.CreateModel(
- name="Application",
- fields=[
- ("is_deleted", models.BooleanField(db_index=True, default=False, verbose_name="Soft Delete")),
- ("app_name", models.CharField(max_length=32, verbose_name="App Name")),
- (
- "app_code",
- models.CharField(max_length=32, primary_key=True, serialize=False, verbose_name="App Code"),
- ),
- ("app_secret", models.TextField(blank=True, null=True, verbose_name="App Secret (Encoded)")),
- ("app_url", models.URLField(blank=True, null=True, verbose_name="App Url")),
- ("app_logo", models.URLField(blank=True, null=True, verbose_name="App Logo")),
- ],
- options={
- "verbose_name": "Application",
- "verbose_name_plural": "Application",
- "ordering": ["app_code"],
- },
- ),
- migrations.CreateModel(
- name="ApplicationManager",
- fields=[
- (
- "id",
- ovinc_client.core.models.UniqIDField(
- default=ovinc_client.core.utils.uniq_id_without_time,
- max_length=32,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "application",
- ovinc_client.core.models.ForeignKey(
- db_constraint=False,
- on_delete=django.db.models.deletion.CASCADE,
- to="application.application",
- verbose_name="Application",
- ),
- ),
- (
- "manager",
- ovinc_client.core.models.ForeignKey(
- db_constraint=False,
- on_delete=django.db.models.deletion.CASCADE,
- to=settings.AUTH_USER_MODEL,
- verbose_name="Manager",
- ),
- ),
- ],
- options={
- "verbose_name": "Application Manager",
- "verbose_name_plural": "Application Manager",
- "ordering": ["id"],
- "unique_together": {("application", "manager")},
- },
- ),
- ]
diff --git a/apps/application/migrations/0002_application_app_desc.py b/apps/application/migrations/0002_application_app_desc.py
deleted file mode 100644
index a81512c..0000000
--- a/apps/application/migrations/0002_application_app_desc.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# pylint: disable=R0801,C0103
-# Generated by Django 4.2 on 2023-04-24 09:35
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
- dependencies = [
- ("application", "0001_initial"),
- ]
-
- operations = [
- migrations.AddField(
- model_name="application",
- name="app_desc",
- field=models.TextField(blank=True, null=True, verbose_name="App Desc"),
- ),
- ]
diff --git a/apps/application/migrations/0003_application_is_hidden.py b/apps/application/migrations/0003_application_is_hidden.py
deleted file mode 100644
index 1d8afd5..0000000
--- a/apps/application/migrations/0003_application_is_hidden.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# pylint: disable=C0103
-# Generated by Django 4.2.16 on 2024-11-20 12:20
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
- dependencies = [
- ("application", "0002_application_app_desc"),
- ]
-
- operations = [
- migrations.AddField(
- model_name="application",
- name="is_hidden",
- field=models.BooleanField(default=False, verbose_name="Is Hidden"),
- ),
- ]
diff --git a/apps/application/migrations/0004_alter_application_app_secret.py b/apps/application/migrations/0004_alter_application_app_secret.py
deleted file mode 100644
index f941c48..0000000
--- a/apps/application/migrations/0004_alter_application_app_secret.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# pylint: disable=C0103
-# Generated by Django 4.2.17 on 2024-12-18 07:18
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
- dependencies = [
- ("application", "0003_application_is_hidden"),
- ]
-
- operations = [
- migrations.AlterField(
- model_name="application",
- name="app_secret",
- field=models.CharField(blank=True, max_length=255, null=True, verbose_name="App Secret"),
- ),
- ]
diff --git a/apps/application/migrations/__init__.py b/apps/application/migrations/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/application/models.py b/apps/application/models.py
deleted file mode 100644
index 2d3442c..0000000
--- a/apps/application/models.py
+++ /dev/null
@@ -1,122 +0,0 @@
-import hashlib
-import secrets
-import time
-from typing import List
-
-from django.conf import settings
-from django.core.cache import cache
-from django.db import models, transaction
-from django.utils.translation import gettext_lazy
-from django_redis.client import DefaultClient
-from ovinc_client.core.constants import MAX_CHAR_LENGTH, SHORT_CHAR_LENGTH
-from ovinc_client.core.models import (
- BaseModel,
- ForeignKey,
- SoftDeletedManager,
- SoftDeletedModel,
- UniqIDField,
-)
-
-from apps.account.models import User
-from core.constants import APP_AUTH_NONCE_CACHE_KEY
-
-cache: DefaultClient
-
-
-class ApplicationObjects(SoftDeletedManager):
- """
- Application Manager Objects
- """
-
- @transaction.atomic()
- def create(self, app_name: str, app_code: str, app_secret: str, managers: List[User], **kwargs) -> "Application":
- """
- Create Application
- """
-
- # create application
- application = super().create(app_name=app_name, app_code=app_code, **kwargs)
-
- # set secret
- application.set_secret(app_secret)
-
- # set managers
- app_managers = [ApplicationManager(application=application, manager=manager) for manager in managers]
- ApplicationManager.objects.bulk_create(app_managers)
-
- return application
-
-
-class Application(SoftDeletedModel):
- """
- Application
- """
-
- app_name = models.CharField(gettext_lazy("App Name"), max_length=SHORT_CHAR_LENGTH)
- app_code = models.CharField(gettext_lazy("App Code"), max_length=SHORT_CHAR_LENGTH, primary_key=True)
- app_secret = models.CharField(gettext_lazy("App Secret"), max_length=MAX_CHAR_LENGTH, blank=True, null=True)
- app_url = models.URLField(gettext_lazy("App Url"), null=True, blank=True)
- app_logo = models.URLField(gettext_lazy("App Logo"), null=True, blank=True)
- app_desc = models.TextField(gettext_lazy("App Desc"), null=True, blank=True)
- is_hidden = models.BooleanField(gettext_lazy("Is Hidden"), default=False)
-
- objects = ApplicationObjects()
-
- class Meta:
- verbose_name = gettext_lazy("Application")
- verbose_name_plural = verbose_name
- ordering = ["app_code"]
-
- def check_sign(self, signature: str, timestamp: str, nonce: str) -> bool:
- """
- check signature
- """
-
- # check timestamp
- if not timestamp.isdigit() or int(timestamp) + settings.APP_AUTH_SIGN_EXPIRE < time.time():
- return False
-
- # check nonce deplicate
- if not cache.add(
- key=APP_AUTH_NONCE_CACHE_KEY.format(appid=self.app_code, nonce=nonce),
- value=signature,
- timeout=settings.APP_AUTH_SIGN_EXPIRE,
- ):
- return False
-
- # check signature
- raw_content = f"{timestamp}-{nonce}-{self.app_secret}"
- expect_signature = hashlib.sha256(raw_content.encode()).hexdigest()
- return secrets.compare_digest(signature, expect_signature)
-
- def set_secret(self, raw_secret: str) -> None:
- """
- set secret
- """
-
- self.app_secret = raw_secret
- self.save(update_fields=["app_secret"])
-
-
-class ApplicationManager(BaseModel):
- """
- Application Manager
- """
-
- id = UniqIDField(gettext_lazy("ID"))
- application = ForeignKey(gettext_lazy("Application"), to="application.Application", on_delete=models.CASCADE)
- manager = ForeignKey(gettext_lazy("Manager"), to="account.User", on_delete=models.CASCADE)
-
- class Meta:
- verbose_name = gettext_lazy("Application Manager")
- verbose_name_plural = verbose_name
- unique_together = ["application", "manager"]
- ordering = ["id"]
-
- @classmethod
- def load_managers(cls, applications: List[Application]) -> List[User]:
- return ApplicationManager.objects.filter(application__in=applications).select_related("manager")
-
- @classmethod
- def load_manage_apps(cls, manager):
- return ApplicationManager.objects.filter(manager=manager).values("application")
diff --git a/apps/application/serializers.py b/apps/application/serializers.py
deleted file mode 100644
index cd25c49..0000000
--- a/apps/application/serializers.py
+++ /dev/null
@@ -1,27 +0,0 @@
-from django.contrib.auth import get_user_model
-from rest_framework.serializers import ModelSerializer
-
-from apps.account.models import User
-from apps.application.models import Application
-
-USER_MODEL: User = get_user_model()
-
-
-class ApplicationSerializer(ModelSerializer):
- """
- Application Info
- """
-
- class Meta:
- model = Application
- fields = ["app_name", "app_code"]
-
-
-class ApplicationListSerializer(ModelSerializer):
- """
- Application List
- """
-
- class Meta:
- model = Application
- fields = ["app_name", "app_code", "app_logo", "app_url", "app_desc"]
diff --git a/apps/application/urls.py b/apps/application/urls.py
deleted file mode 100644
index 699802c..0000000
--- a/apps/application/urls.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from rest_framework.routers import DefaultRouter
-
-from apps.application.views import ApplicationViewSet
-
-router = DefaultRouter()
-router.register("", ApplicationViewSet)
-
-urlpatterns = router.urls
diff --git a/apps/application/views.py b/apps/application/views.py
deleted file mode 100644
index e0fcc13..0000000
--- a/apps/application/views.py
+++ /dev/null
@@ -1,36 +0,0 @@
-from django.contrib.auth import get_user_model
-from ovinc_client.core.auth import SessionAuthenticate
-from ovinc_client.core.viewsets import MainViewSet
-from rest_framework.decorators import action
-from rest_framework.response import Response
-
-from apps.account.models import User
-from apps.application.models import Application
-from apps.application.serializers import (
- ApplicationListSerializer,
- ApplicationSerializer,
-)
-
-USER_MODEL: User = get_user_model()
-
-
-class ApplicationViewSet(MainViewSet):
- """
- Application
- """
-
- queryset = Application.get_queryset()
- serializer_class = ApplicationSerializer
-
- @action(methods=["GET"], detail=False, authentication_classes=[SessionAuthenticate])
- def all(self, request, *args, **kwargs):
- """
- list all applications
- """
-
- # filter
- queryset = Application.get_queryset().filter(is_hidden=False)
-
- # response
- serializer = ApplicationListSerializer(queryset, many=True)
- return Response(serializer.data)
diff --git a/apps/cel/__init__.py b/apps/cel/__init__.py
deleted file mode 100644
index df97b04..0000000
--- a/apps/cel/__init__.py
+++ /dev/null
@@ -1,21 +0,0 @@
-import os
-
-from celery import Celery
-from celery.schedules import crontab
-from django.conf import settings
-
-os.environ.setdefault("DJANGO_SETTINGS_MODULE", "entry.settings")
-os.environ.setdefault("C_FORCE_ROOT", "True")
-
-app = Celery("main", broker=settings.BROKER_URL)
-app.config_from_object("django.conf:settings", namespace="CELERY")
-app.autodiscover_tasks()
-
-# Schedule Tasks
-app.conf.beat_schedule = {
- "celery_debug": {
- "task": "apps.cel.tasks.debug.celery_debug",
- "schedule": crontab(minute="*"),
- "args": (),
- },
-}
diff --git a/apps/cel/constants.py b/apps/cel/constants.py
deleted file mode 100644
index b39074d..0000000
--- a/apps/cel/constants.py
+++ /dev/null
@@ -1 +0,0 @@
-CELERY_RETRY_SLEEP = 1
diff --git a/apps/cel/management/__init__.py b/apps/cel/management/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/cel/management/commands/__init__.py b/apps/cel/management/commands/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/cel/management/commands/celery.py b/apps/cel/management/commands/celery.py
deleted file mode 100644
index 0669959..0000000
--- a/apps/cel/management/commands/celery.py
+++ /dev/null
@@ -1,22 +0,0 @@
-import sys
-
-from django.core.management import BaseCommand
-
-from apps.cel import app
-
-
-class Command(BaseCommand):
- """
- Celery Command
- """
-
- def handle(self, *args, **options):
- argv = sys.argv[2:]
- app.start(argv)
-
- def add_arguments(self, parser):
- parser.add_argument("worker", nargs="*")
- parser.add_argument("beat", nargs="*")
- parser.add_argument("-l")
- parser.add_argument("-f")
- parser.add_argument("-c")
diff --git a/apps/cel/tasks/__init__.py b/apps/cel/tasks/__init__.py
deleted file mode 100644
index db8bd97..0000000
--- a/apps/cel/tasks/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-from apps.cel.tasks.debug import celery_debug
-from apps.cel.tasks.notice import send_notice
-
-__all__ = [
- "celery_debug",
- "send_notice",
-]
diff --git a/apps/cel/tasks/debug.py b/apps/cel/tasks/debug.py
deleted file mode 100644
index 611af16..0000000
--- a/apps/cel/tasks/debug.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from ovinc_client.core.logger import celery_logger
-
-from apps.cel import app
-from apps.cel.utils import task_lock
-
-
-@app.task(bind=True)
-@task_lock()
-def celery_debug(self):
- celery_logger.info(f"[CeleryDebug] Start {self.request.id}")
- celery_logger.info(f"[CeleryDebug] End {self.request.id}")
diff --git a/apps/cel/tasks/notice.py b/apps/cel/tasks/notice.py
deleted file mode 100644
index 454ed06..0000000
--- a/apps/cel/tasks/notice.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from ovinc_client.core.logger import celery_logger
-
-from apps.cel import app
-
-
-@app.task(bind=True)
-def send_notice(self, notice_type: str, **kwargs):
- # pylint: disable=C0415,R0401
- from apps.notice.utils import NoticeBase
-
- celery_logger.info(f"[SendNotice] Start {self.request.id}")
- NoticeBase.get_instance(notice_type, **kwargs).send()
- celery_logger.info(f"[SendNotice] End {self.request.id}")
diff --git a/apps/cel/utils.py b/apps/cel/utils.py
deleted file mode 100644
index 70c03d6..0000000
--- a/apps/cel/utils.py
+++ /dev/null
@@ -1,128 +0,0 @@
-import time
-from functools import wraps
-
-from django.core.cache import cache
-from ovinc_client.core.logger import logger
-
-from apps.cel.constants import CELERY_RETRY_SLEEP
-
-
-class LockKey:
- """
- Lock Key
- """
-
- _key_prefix = "task-lock"
- timeout = 24 * 60 * 60
-
- def __init__(self, func: callable, *args, **kwargs):
- self.func = func
- self.args = args
- self.kwargs = kwargs
-
- @property
- def _key(self):
- """
- cache key
- """
-
- return self.func.__name__
-
- @property
- def key(self):
- """
- cache key
- """
-
- return f"{self._key_prefix}:{self._key}"
-
-
-class TaskLock:
- def __init__(self, func: callable, lock_key: LockKey.__class__ = LockKey, retry: bool = False, *args, **kwargs):
- self.func = func
- self.lock_key = lock_key(func, *args, **kwargs)
- self.retry = retry
- self.args = args
- self.kwargs = kwargs
-
- # pylint: disable=R1710
- def __call__(self):
- """
- run task or skip
- """
-
- # check status
- if not self.set_status():
- logger.warning("[TaskLockExecuteTask] Duplicate => %s", self.func)
- if self.retry:
- self.retry_task()
- return
-
- # run task
- try:
- logger.debug(
- "[TaskLockExecuteTask] Task => %s; LockKey => %s; Args => %s; Kwargs => %s",
- self.func,
- self.lock_key,
- self.args,
- self.kwargs,
- )
- return self.func(*self.args, **self.kwargs)
- except Exception as err:
- raise err
- # clean status
- finally:
- self.clean_status()
-
- def retry_task(self):
- result = self.args[0].delay(*self.args[1:], **self.kwargs)
- time.sleep(CELERY_RETRY_SLEEP)
- logger.info("[TaskLockExecuteTask] Retry => %s; Result => %s", self.func, result)
-
- @property
- def cache_key(self) -> str:
- """
- lock key and timeout
- """
-
- return self.lock_key.key
-
- @property
- def cache_timeout(self) -> int:
- """
- lick timeout
- """
-
- return self.lock_key.timeout
-
- def set_status(self) -> bool:
- """
- set running
- """
-
- return cache.set(self.cache_key, True, timeout=self.cache_timeout, nx=True)
-
- def clean_status(self) -> None:
- """
- set running finished
- """
-
- cache.delete(self.cache_key)
-
-
-def task_lock(func: callable = None, lock_key: LockKey.__class__ = LockKey, retry: bool = False):
- """
- wait task finished
- """
-
- @wraps(func)
- def wrapper(task: callable):
- @wraps(task)
- def warp_func(*args, **kwargs):
- TaskLock(task, lock_key, retry, *args, **kwargs)()
-
- return warp_func
-
- if callable(func):
- return wrapper(func)
- return wrapper
diff --git a/apps/home/__init__.py b/apps/home/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/home/admin.py b/apps/home/admin.py
deleted file mode 100644
index b1c3fbc..0000000
--- a/apps/home/admin.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from django.contrib import admin
-
-from apps.home.models import MetaConfig
-
-
-@admin.register(MetaConfig)
-class MetaConfigAdmin(admin.ModelAdmin):
- list_display = ["key", "is_public", "val"]
- ordering = ["key"]
diff --git a/apps/home/apps.py b/apps/home/apps.py
deleted file mode 100644
index 1d2b9d1..0000000
--- a/apps/home/apps.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from django.apps import AppConfig
-from django.db.models.signals import post_migrate
-from django.utils.translation import gettext_lazy
-
-
-class HomeConfig(AppConfig):
- default_auto_field = "django.db.models.BigAutoField"
- name = "apps.home"
- verbose_name = gettext_lazy("Home Module")
-
- def ready(self):
- # pylint: disable=C0415
- from apps.home.utils import registry_build_in_config_key
-
- post_migrate.connect(registry_build_in_config_key, self)
diff --git a/apps/home/constants.py b/apps/home/constants.py
deleted file mode 100644
index c33d282..0000000
--- a/apps/home/constants.py
+++ /dev/null
@@ -1,34 +0,0 @@
-from dataclasses import dataclass
-from typing import Tuple
-
-# pylint: disable=C0103
-CONFIG_DEFAULT_T = Tuple[str, str]
-
-
-# pylint: disable=C0103,R0902
-@dataclass
-class BuildInKeys:
- """
- Build in config keys
- """
-
- LOGO_URL: CONFIG_DEFAULT_T = "logo_url", "/favicon.ico"
- WEBSITE_TITLE: CONFIG_DEFAULT_T = "website_title", ""
- BRAND_NAME: CONFIG_DEFAULT_T = "brand_name", "OVINC"
- BRAND_TITLE: CONFIG_DEFAULT_T = "brand_title", "OVINC CN"
- BRAND_DESC: CONFIG_DEFAULT_T = "brand_desc", ""
- BRAND_VISION: CONFIG_DEFAULT_T = "brand_vision", ""
- CONTACT_PICTURE_URL: CONFIG_DEFAULT_T = "contact_picture_url", ""
- CONTACT_MAIL: CONFIG_DEFAULT_T = "contact_email", ""
- CONTACT_PHONE: CONFIG_DEFAULT_T = "contact_phone", ""
- CONTACT_PLACE: CONFIG_DEFAULT_T = "contact_place", ""
- COPYRIGHT: CONFIG_DEFAULT_T = "copyright", "OVINC CN"
- MIIT_FILLING_CODE: CONFIG_DEFAULT_T = "miit_filling_code", ""
- MIIT_FILLING_URL: CONFIG_DEFAULT_T = "miit_filling_url", ""
- GONGAN_FILLING_ID: CONFIG_DEFAULT_T = "gongan_filling_id", ""
- GONGAN_FILLING_URL: CONFIG_DEFAULT_T = "gongan_filling_url", ""
- USER_AGREEMENT: CONFIG_DEFAULT_T = "user_agreement", ""
- PRIVACY_AGREEMENT: CONFIG_DEFAULT_T = "privacy_agreement", ""
- BACKGROUND_IMAGE: CONFIG_DEFAULT_T = "background_image", ""
- USERNAME_EXTRA_REGEX: CONFIG_DEFAULT_T = "username_extra_regex", ""
- REGISTRY_LOCKED: CONFIG_DEFAULT_T = "registry_locked", ""
diff --git a/apps/home/exceptions.py b/apps/home/exceptions.py
deleted file mode 100644
index e4eb8e2..0000000
--- a/apps/home/exceptions.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from django.utils.translation import gettext_lazy
-from rest_framework import status
-from rest_framework.exceptions import APIException
-
-
-class LanguageCodeInvalid(APIException):
- status_code = status.HTTP_400_BAD_REQUEST
- default_detail = gettext_lazy("Language Code Invalid")
-
-
-class SearchIPFailed(APIException):
- status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
- default_detail = gettext_lazy("Search IP Failed")
diff --git a/apps/home/migrations/0001_initial.py b/apps/home/migrations/0001_initial.py
deleted file mode 100644
index 9ac4e94..0000000
--- a/apps/home/migrations/0001_initial.py
+++ /dev/null
@@ -1,38 +0,0 @@
-# pylint: disable=R0801,C0103
-# Generated by Django 4.2.13 on 2024-07-10 09:44
-
-import ovinc_client.core.models
-import ovinc_client.core.utils
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
- initial = True
-
- dependencies = []
-
- operations = [
- migrations.CreateModel(
- name="MetaConfig",
- fields=[
- (
- "id",
- ovinc_client.core.models.UniqIDField(
- default=ovinc_client.core.utils.uniq_id_without_time,
- max_length=32,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- ("key", models.CharField(max_length=255, unique=True, verbose_name="Config Key")),
- ("val", models.TextField(blank=True, null=True, verbose_name="Config Value")),
- ("is_public", models.BooleanField(db_index=True, default=True, verbose_name="Is Public")),
- ],
- options={
- "verbose_name": "Meta Config",
- "verbose_name_plural": "Meta Configs",
- "ordering": ["key"],
- },
- ),
- ]
diff --git a/apps/home/migrations/__init__.py b/apps/home/migrations/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/home/models.py b/apps/home/models.py
deleted file mode 100644
index 60ac1a7..0000000
--- a/apps/home/models.py
+++ /dev/null
@@ -1,29 +0,0 @@
-from typing import Dict
-
-from django.db import models
-from django.db.models import Q
-from django.utils.translation import gettext_lazy
-from ovinc_client.core.models import BaseModel, UniqIDField
-
-
-class MetaConfig(BaseModel):
- """
- Meta Config
- """
-
- id = UniqIDField(gettext_lazy("ID"), primary_key=True)
- key = models.CharField(gettext_lazy("Config Key"), unique=True, max_length=255)
- val = models.TextField(gettext_lazy("Config Value"), null=True, blank=True)
- is_public = models.BooleanField(gettext_lazy("Is Public"), default=True, db_index=True)
-
- class Meta:
- verbose_name = gettext_lazy("Meta Config")
- verbose_name_plural = gettext_lazy("Meta Configs")
- ordering = ["key"]
-
- def __str__(self):
- return str(self.key)
-
- @classmethod
- def as_map(cls, condition: Q) -> Dict[str, str]:
- return {config.key: config.val for config in cls.objects.filter(condition)}
diff --git a/apps/home/serializers.py b/apps/home/serializers.py
deleted file mode 100644
index a068346..0000000
--- a/apps/home/serializers.py
+++ /dev/null
@@ -1,25 +0,0 @@
-from django.utils.translation import check_for_language, gettext_lazy
-from rest_framework import serializers
-
-from apps.home.exceptions import LanguageCodeInvalid
-
-
-class I18nRequestSerializer(serializers.Serializer):
- """
- I18n
- """
-
- language = serializers.CharField(label=gettext_lazy("Language Code"))
-
- def validate_language(self, language: str) -> str:
- if check_for_language(language):
- return language
- raise LanguageCodeInvalid()
-
-
-class MetaConfigQuerySerializer(serializers.Serializer):
- """
- MetaConfig
- """
-
- key = serializers.CharField(label=gettext_lazy("Config Key"), required=False)
diff --git a/apps/home/urls.py b/apps/home/urls.py
deleted file mode 100644
index 510dada..0000000
--- a/apps/home/urls.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from rest_framework.routers import DefaultRouter
-
-from apps.home.views import HomeView, I18nViewSet, MetaConfigViewSet
-
-router = DefaultRouter()
-router.register("", HomeView)
-router.register("i18n", I18nViewSet, basename="i18n")
-router.register("meta", MetaConfigViewSet)
-
-urlpatterns = router.urls
diff --git a/apps/home/utils.py b/apps/home/utils.py
deleted file mode 100644
index 0ecd49b..0000000
--- a/apps/home/utils.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from dataclasses import fields
-
-from apps.home.constants import BuildInKeys
-from apps.home.models import MetaConfig
-
-
-def registry_build_in_config_key(*args, **kwargs):
- """
- Registry Build In Config Key To DB
- """
-
- for field in fields(BuildInKeys):
- key, val = getattr(BuildInKeys, field.name)
- MetaConfig.objects.get_or_create(key=key, defaults={"val": val})
diff --git a/apps/home/views.py b/apps/home/views.py
deleted file mode 100644
index 9f786d0..0000000
--- a/apps/home/views.py
+++ /dev/null
@@ -1,88 +0,0 @@
-from django.conf import settings
-from django.conf.global_settings import LANGUAGE_COOKIE_NAME
-from django.contrib.auth import get_user_model
-from django.db.models import Q
-from ovinc_client.core.auth import SessionAuthenticate
-from ovinc_client.core.viewsets import MainViewSet
-from rest_framework.response import Response
-
-from apps.account.models import User
-from apps.home.models import MetaConfig
-from apps.home.serializers import I18nRequestSerializer, MetaConfigQuerySerializer
-
-USER_MODEL: User = get_user_model()
-
-
-class HomeView(MainViewSet):
- """
- Home View
- """
-
- queryset = USER_MODEL.get_queryset()
- authentication_classes = [SessionAuthenticate]
-
- def list(self, request, *args, **kwargs):
- msg = f"[{request.method}] Connect Success"
- return Response(
- {
- "resp": msg,
- "user": request.user.username,
- }
- )
-
-
-class I18nViewSet(MainViewSet):
- """
- International
- """
-
- authentication_classes = [SessionAuthenticate]
-
- def create(self, request, *args, **kwargs):
- """
- Change Language
- """
-
- # Varify Request Data
- request_serializer = I18nRequestSerializer(data=request.data)
- request_serializer.is_valid(raise_exception=True)
- request_data = request_serializer.validated_data
-
- # Change Lang
- lang_code = request_data["language"]
- response = Response()
- response.set_cookie(
- LANGUAGE_COOKIE_NAME,
- lang_code,
- max_age=settings.SESSION_COOKIE_AGE,
- domain=settings.SESSION_COOKIE_DOMAIN,
- path=settings.SESSION_COOKIE_PATH,
- secure=settings.SESSION_COOKIE_SECURE or None,
- httponly=settings.SESSION_COOKIE_HTTPONLY or None,
- samesite=settings.SESSION_COOKIE_SAMESITE,
- )
- return response
-
-
-class MetaConfigViewSet(MainViewSet):
- """
- Meta Config
- """
-
- queryset = MetaConfig.get_queryset()
- authentication_classes = [SessionAuthenticate]
-
- def list(self, request, *args, **kwargs):
- """
- Meta Config
- """
-
- request_serializer = MetaConfigQuerySerializer(data=request.query_params)
- request_serializer.is_valid(raise_exception=True)
- request_data = request_serializer.validated_data
-
- condition = Q(is_public=True)
- if request_data:
- condition &= Q(**request_data)
-
- return Response(MetaConfig.as_map(condition))
diff --git a/apps/notice/__init__.py b/apps/notice/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/notice/admin.py b/apps/notice/admin.py
deleted file mode 100644
index 19d04a5..0000000
--- a/apps/notice/admin.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from django.contrib import admin
-
-from apps.notice.models import NoticeLog, Robot
-
-
-@admin.register(Robot)
-class RobotAdmin(admin.ModelAdmin):
- list_display = ["id", "name", "webhook"]
- ordering = ["name"]
-
-
-@admin.register(NoticeLog)
-class NoticeLogAdmin(admin.ModelAdmin):
- list_display = ["id", "receivers", "content", "result", "send_at"]
- ordering = ["-send_at"]
diff --git a/apps/notice/apps.py b/apps/notice/apps.py
deleted file mode 100644
index 0b254f0..0000000
--- a/apps/notice/apps.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from django.apps import AppConfig
-from django.utils.translation import gettext_lazy
-
-
-class NoticeConfig(AppConfig):
- default_auto_field = "django.db.models.BigAutoField"
- name = "apps.notice"
- verbose_name = gettext_lazy("Notice")
diff --git a/apps/notice/constants.py b/apps/notice/constants.py
deleted file mode 100644
index 0c56e25..0000000
--- a/apps/notice/constants.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from django.utils.translation import gettext_lazy
-from ovinc_client.core.models import TextChoices
-
-
-class NoticeWayChoices(TextChoices):
- MSG = "msg", gettext_lazy("msg")
- MAIL = "mail", gettext_lazy("mail")
- ROBOT = "robot", gettext_lazy("robot")
diff --git a/apps/notice/migrations/0001_initial.py b/apps/notice/migrations/0001_initial.py
deleted file mode 100644
index beb4f56..0000000
--- a/apps/notice/migrations/0001_initial.py
+++ /dev/null
@@ -1,62 +0,0 @@
-# pylint: disable=R0801,C0103
-# Generated by Django 4.2 on 2023-04-15 08:59
-
-import ovinc_client.core.models
-import ovinc_client.core.utils
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
- initial = True
-
- dependencies = []
-
- operations = [
- migrations.CreateModel(
- name="NoticeLog",
- fields=[
- (
- "id",
- ovinc_client.core.models.UniqIDField(
- default=ovinc_client.core.utils.uniq_id_without_time,
- max_length=32,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- ("receivers", models.JSONField(verbose_name="Receivers")),
- ("content", models.JSONField(verbose_name="Content")),
- ("extra_params", models.JSONField(default=dict, null=True, verbose_name="Extra Params")),
- ("result", models.TextField(blank=True, null=True, verbose_name="Result")),
- ("send_at", models.DateTimeField(auto_now_add=True, verbose_name="Send At")),
- ],
- options={
- "verbose_name": "Notice Log",
- "verbose_name_plural": "Notice Log",
- "ordering": ["-send_at"],
- },
- ),
- migrations.CreateModel(
- name="Robot",
- fields=[
- (
- "id",
- ovinc_client.core.models.UniqIDField(
- default=ovinc_client.core.utils.uniq_id_without_time,
- max_length=32,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- ("webhook", models.CharField(max_length=255, verbose_name="Webhook")),
- ("name", models.CharField(max_length=32, verbose_name="Name")),
- ],
- options={
- "verbose_name": "Robot",
- "verbose_name_plural": "Robot",
- "ordering": ["id"],
- },
- ),
- ]
diff --git a/apps/notice/migrations/0002_alter_noticelog_send_at_alter_robot_name.py b/apps/notice/migrations/0002_alter_noticelog_send_at_alter_robot_name.py
deleted file mode 100644
index 364223f..0000000
--- a/apps/notice/migrations/0002_alter_noticelog_send_at_alter_robot_name.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# pylint: disable=R0801,C0103
-# Generated by Django 4.2.11 on 2024-05-08 03:02
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
- dependencies = [
- ("notice", "0001_initial"),
- ]
-
- operations = [
- migrations.AlterField(
- model_name="noticelog",
- name="send_at",
- field=models.DateTimeField(auto_now_add=True, db_index=True, verbose_name="Send At"),
- ),
- migrations.AlterField(
- model_name="robot",
- name="name",
- field=models.CharField(db_index=True, max_length=32, verbose_name="Name"),
- ),
- ]
diff --git a/apps/notice/migrations/__init__.py b/apps/notice/migrations/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/notice/models.py b/apps/notice/models.py
deleted file mode 100644
index 9ae339d..0000000
--- a/apps/notice/models.py
+++ /dev/null
@@ -1,37 +0,0 @@
-from django.db import models
-from django.utils.translation import gettext_lazy
-from ovinc_client.core.constants import MAX_CHAR_LENGTH, SHORT_CHAR_LENGTH
-from ovinc_client.core.models import BaseModel, UniqIDField
-
-
-class NoticeLog(BaseModel):
- """
- Notice Log
- """
-
- id = UniqIDField(gettext_lazy("ID"))
- receivers = models.JSONField(gettext_lazy("Receivers"))
- content = models.JSONField(gettext_lazy("Content"))
- extra_params = models.JSONField(gettext_lazy("Extra Params"), default=dict, null=True)
- result = models.TextField(gettext_lazy("Result"), blank=True, null=True)
- send_at = models.DateTimeField(gettext_lazy("Send At"), auto_now_add=True, db_index=True)
-
- class Meta:
- verbose_name = gettext_lazy("Notice Log")
- verbose_name_plural = verbose_name
- ordering = ["-send_at"]
-
-
-class Robot(BaseModel):
- """
- Robot of Wecom or Feishu
- """
-
- id = UniqIDField(gettext_lazy("ID"))
- webhook = models.CharField(gettext_lazy("Webhook"), max_length=MAX_CHAR_LENGTH)
- name = models.CharField(gettext_lazy("Name"), max_length=SHORT_CHAR_LENGTH, db_index=True)
-
- class Meta:
- verbose_name = gettext_lazy("Robot")
- verbose_name_plural = verbose_name
- ordering = ["id"]
diff --git a/apps/notice/rates.py b/apps/notice/rates.py
deleted file mode 100644
index 2c1986d..0000000
--- a/apps/notice/rates.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from rest_framework.throttling import SimpleRateThrottle
-
-
-class AppRateThrottle(SimpleRateThrottle):
- """
- App Throttle
- """
-
- scope = "app"
-
- def get_cache_key(self, request, view):
- return f'rate:{self.scope}:{getattr(request.user, "app_code", "")}'
diff --git a/apps/notice/serializers.py b/apps/notice/serializers.py
deleted file mode 100644
index 191ecc6..0000000
--- a/apps/notice/serializers.py
+++ /dev/null
@@ -1,71 +0,0 @@
-from django.conf import settings
-from django.utils.translation import gettext_lazy
-from rest_framework import serializers
-
-from apps.notice.models import Robot
-
-
-class NoticeRequestSerializer(serializers.Serializer):
- """
- Notice Base
- """
-
- usernames = serializers.ListField(
- label=gettext_lazy("Username List"), child=serializers.CharField(label=gettext_lazy("Username"))
- )
- content = serializers.JSONField(label=gettext_lazy("Content"))
-
-
-class MailContentSerializer(serializers.Serializer):
- """
- Mail Content
- """
-
- title = serializers.CharField(label=gettext_lazy("Title"))
- content = serializers.CharField(label=gettext_lazy("Content"))
-
-
-class MailRequestSerialzier(NoticeRequestSerializer):
- """
- Mail
- """
-
- content = MailContentSerializer(label=gettext_lazy("Content"))
-
-
-class SmsContentSerializer(serializers.Serializer):
- """
- SMS Content
- """
-
- tid = serializers.CharField(label=gettext_lazy("Template ID"))
- params = serializers.ListSerializer(label=gettext_lazy("Params"), child=serializers.CharField())
-
-
-class SmsRequestSerializer(NoticeRequestSerializer):
- """
- SMS
- """
-
- content = SmsContentSerializer(label=gettext_lazy("Content"))
-
-
-class RegistryRobotSerializer(serializers.ModelSerializer):
- """
- registry robot
- """
-
- class Meta:
- model = Robot
- fields = "__all__"
-
-
-class RobotRequestSerializer(serializers.Serializer):
- """
- Robot
- """
-
- robots = serializers.ListField(
- label=gettext_lazy("Robot ID"), child=serializers.CharField(label=gettext_lazy("Robot ID"))
- )
- content = serializers.JSONField(label=gettext_lazy("Content"), help_text=settings.NOTICE_ROBOT_CONTENT_HELP)
diff --git a/apps/notice/urls.py b/apps/notice/urls.py
deleted file mode 100644
index 8b05e17..0000000
--- a/apps/notice/urls.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from rest_framework.routers import DefaultRouter
-
-from apps.notice.views import NoticeViewSet
-
-router = DefaultRouter()
-router.register("", NoticeViewSet)
-
-urlpatterns = router.urls
diff --git a/apps/notice/utils/__init__.py b/apps/notice/utils/__init__.py
deleted file mode 100644
index 99e1900..0000000
--- a/apps/notice/utils/__init__.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from apps.notice.utils.base import NoticeBase
-from apps.notice.utils.mail import MailHandler
-from apps.notice.utils.msg import MsgHandler
-from apps.notice.utils.robot import RobotHandler
-
-__all__ = [
- "NoticeBase",
- "MailHandler",
- "MsgHandler",
- "RobotHandler",
-]
diff --git a/apps/notice/utils/base.py b/apps/notice/utils/base.py
deleted file mode 100644
index 828421a..0000000
--- a/apps/notice/utils/base.py
+++ /dev/null
@@ -1,96 +0,0 @@
-import abc
-import traceback
-from typing import List, Union
-
-from django.contrib.auth import get_user_model
-from django.utils.module_loading import import_string
-from django.utils.translation import gettext
-from ovinc_client.core.logger import logger
-
-from apps.account.models import User
-from apps.cel.tasks import send_notice
-from apps.notice.constants import NoticeWayChoices
-from apps.notice.models import NoticeLog
-
-USER_MODEL: User = get_user_model()
-
-
-class NoticeBase:
- """
- Notice Base
- """
-
- # pylint: disable=R1710
- @classmethod
- def send_notice(cls, notice_type: str, is_async: bool = True, **kwargs) -> None:
- if is_async:
- send_notice.delay(notice_type, **kwargs)
- return None
- send_notice(notice_type, **kwargs)
-
- @classmethod
- def get_instance(cls, notice_type: str, **kwargs) -> "NoticeBase":
- instance_map = {_value: f"apps.notice.utils.{_value.capitalize()}Handler" for _value in NoticeWayChoices.values}
- try:
- notice_type_class = import_string(instance_map[notice_type])
- return notice_type_class(**kwargs)
- except KeyError as err:
- raise KeyError(gettext("Notice Type not Exists => %s") % notice_type) from err
-
- def __init__(self, usernames: List[str], content: Union[dict, str], **kwargs) -> None:
- self.kwargs = kwargs
- self.receivers = self._load_receivers(usernames)
- self.content = self._build_content(content)
-
- @property
- @abc.abstractmethod
- def property_key(self) -> str:
- raise NotImplementedError
-
- def send(self) -> None:
- """
- send notice
- """
-
- logger.info("[%s SendNotice] Content => %s", self.__class__.__name__, self.content)
- try:
- result = self._send()
- logger.info("[%s SendNoticeSuccess] Result => %s", self.__class__.__name__, result)
- except Exception as err: # pylint: disable=W0718
- msg = traceback.format_exc()
- logger.error("[%s SendNoticeFailed] Err => %s; Detail => %s", self.__class__.__name__, err, msg)
- result = {"err": str(err)}
- NoticeLog.objects.create(
- receivers=self.receivers, content=self.content, extra_params=self.kwargs, result=str(result)
- )
- return result
-
- @abc.abstractmethod
- def _send(self) -> None:
- """
- send notice
- """
-
- raise NotImplementedError
-
- def _load_receivers(self, usernames: List[str]) -> List[str]:
- """
- trans username to receiver
- """
-
- logger.info("[%s LoadReceivers] Usernames => %s", self.__class__.__name__, usernames)
- # load user
- users = list(User.objects.filter(username__in=usernames))
- receivers = list(getattr(u, self.property_key) for u in users if getattr(u, self.property_key, None))
- if not receivers and "receivers" in self.kwargs:
- receivers = self.kwargs["receivers"]
- logger.info("[%s LoadReceivers] Receivers => %s", self.__class__.__name__, receivers)
- # return
- return receivers
-
- def _build_content(self, content: Union[dict, str]) -> any:
- """
- build content for notice
- """
-
- return content
diff --git a/apps/notice/utils/mail.py b/apps/notice/utils/mail.py
deleted file mode 100644
index 05abb13..0000000
--- a/apps/notice/utils/mail.py
+++ /dev/null
@@ -1,36 +0,0 @@
-from typing import List, Union
-
-from django.conf import settings
-from django.core.mail import send_mail
-from django.core.mail.backends.smtp import EmailBackend
-
-from apps.notice.utils.base import NoticeBase
-
-
-class MailHandler(NoticeBase):
- """
- Send Mail
- """
-
- def __init__(self, usernames: List[str], content: Union[dict, str], **kwargs) -> None:
- super().__init__(usernames=usernames, content=content, **kwargs)
- self.host = settings.NOTICE_MAIL_HOST
- self.port = settings.NOTICE_MAIL_PORT
- self.username = settings.NOTICE_MAIL_USERNAME
- self.password = settings.NOTICE_MAIL_PASSWORD
-
- @property
- def property_key(self) -> str:
- return "email_address"
-
- def _send(self) -> None:
- connection = EmailBackend(
- host=self.host, port=self.port, username=self.username, password=self.password, use_ssl=True
- )
- send_mail(
- subject=self.content["title"],
- message=self.content["content"],
- from_email=self.username,
- recipient_list=self.receivers,
- connection=connection,
- )
diff --git a/apps/notice/utils/msg.py b/apps/notice/utils/msg.py
deleted file mode 100644
index 465aad7..0000000
--- a/apps/notice/utils/msg.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from typing import List, Union
-
-from django.conf import settings
-from tencentcloud.common import credential
-from tencentcloud.sms.v20210111 import models, sms_client
-
-from apps.notice.utils.base import NoticeBase
-
-
-class MsgHandler(NoticeBase):
- """
- Send SMS
- """
-
- def __init__(self, usernames: List[str], content: Union[dict, str], **kwargs):
- super().__init__(usernames, content, **kwargs)
- self._cred = credential.Credential(settings.NOTICE_MSG_TCLOUD_ID, settings.NOTICE_MSG_TCLOUD_KEY)
- self._client = sms_client.SmsClient(self._cred, settings.NOTICE_MSG_TCLUD_REGION)
-
- @property
- def property_key(self):
- return "phone_number"
-
- def _send(self) -> None:
- req = models.SendSmsRequest()
- req.SmsSdkAppId = settings.NOTICE_MSG_TCLOUD_APP
- req.SignName = settings.NOTICE_MSG_TCLOUD_SIGN
- req.TemplateId = self.content["tid"]
- req.TemplateParamSet = self.content["params"]
- req.PhoneNumberSet = self.receivers
- resp = self._client.SendSms(req)
- return resp.to_json_string(indent=2)
diff --git a/apps/notice/utils/robot.py b/apps/notice/utils/robot.py
deleted file mode 100644
index 9be49b0..0000000
--- a/apps/notice/utils/robot.py
+++ /dev/null
@@ -1,42 +0,0 @@
-from typing import List, Union
-
-import httpx
-from ovinc_client.core.logger import logger
-
-from apps.notice.models import Robot
-from apps.notice.utils.base import NoticeBase
-
-
-class RobotHandler(NoticeBase):
- """
- Robot
- """
-
- property_key = None
-
- # pylint: disable=W0246
- def __init__(self, robots: List[str], content: Union[dict, str], **kwargs) -> None:
- super().__init__(robots, content, **kwargs)
-
- def _send(self) -> list:
- result = []
- with httpx.Client() as client:
- for robot in self.receivers:
- resp = client.post(robot, json=self.content)
- result.append(resp.json())
- return result
-
- # pylint: disable=W0237
- def _load_receivers(self, robot_ids: List[str]) -> List[str]:
- """
- robot id
- """
-
- logger.info("[%s LoadReceivers] Robots => %s", self.__class__.__name__, robot_ids)
- robots = Robot.objects.filter(id__in=robot_ids)
- logger.info(
- "[%s LoadReceivers] Receivers => %s",
- self.__class__.__name__,
- [f"{r.id}:{r.webhook}" for r in robots],
- )
- return [r.webhook for r in robots]
diff --git a/apps/notice/views.py b/apps/notice/views.py
deleted file mode 100644
index 5990a11..0000000
--- a/apps/notice/views.py
+++ /dev/null
@@ -1,91 +0,0 @@
-from django.shortcuts import get_object_or_404
-from ovinc_client.core.viewsets import MainViewSet
-from rest_framework.decorators import action
-from rest_framework.response import Response
-
-from apps.notice.constants import NoticeWayChoices
-from apps.notice.models import NoticeLog, Robot
-from apps.notice.rates import AppRateThrottle
-from apps.notice.serializers import (
- MailRequestSerialzier,
- RegistryRobotSerializer,
- RobotRequestSerializer,
- SmsRequestSerializer,
-)
-from apps.notice.utils import NoticeBase
-from core.auth import ApplicationAuthenticate
-
-
-class NoticeViewSet(MainViewSet):
- """
- Notice
- """
-
- queryset = NoticeLog.get_queryset()
- authentication_classes = [ApplicationAuthenticate]
- throttle_classes = [AppRateThrottle]
-
- @action(methods=["POST"], detail=False)
- def mail(self, request, *args, **kwargs):
- """
- mail
- """
-
- # validate request
- request_serializer = MailRequestSerialzier(data=request.data)
- request_serializer.is_valid(raise_exception=True)
- request_data = request_serializer.validated_data
-
- # send
- NoticeBase.send_notice(NoticeWayChoices.MAIL, **request_data)
- return Response()
-
- @action(methods=["POST"], detail=False)
- def sms(self, request, *args, **kwargs):
- """
- sms
- """
-
- # validate request
- request_serializer = SmsRequestSerializer(data=request.data)
- request_serializer.is_valid(raise_exception=True)
- request_data = request_serializer.validated_data
-
- # send
- NoticeBase.send_notice(NoticeWayChoices.MSG, **request_data)
- return Response()
-
- @action(methods=["POST"], detail=False)
- def robot(self, request, *args, **kwargs):
- """
- robot of Wecom or Feishu
- """
-
- # validate request
- request_serializer = RobotRequestSerializer(data=request.data)
- request_serializer.is_valid(raise_exception=True)
- request_data = request_serializer.validated_data
-
- # send
- NoticeBase.send_notice(NoticeWayChoices.ROBOT, **request_data)
- return Response()
-
- @action(methods=["POST"], detail=False)
- def registry_robot(self, request, *args, **kwargs):
- """
- registry robot
- """
-
- # get instance
- instance = None
- if request.data.get("id"):
- instance = get_object_or_404(Robot, pk=request.data.pop("id"))
-
- # validate request
- request_serializer = RegistryRobotSerializer(instance=instance, data=request.data, partial=bool(instance))
- request_serializer.is_valid(raise_exception=True)
-
- # save
- request_serializer.save()
-
- return Response(request_serializer.data)
diff --git a/apps/tcloud/__init__.py b/apps/tcloud/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/tcloud/admin.py b/apps/tcloud/admin.py
deleted file mode 100644
index 163a8f1..0000000
--- a/apps/tcloud/admin.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from django.contrib import admin
-
-from apps.tcloud.models import AuditCallback
-
-
-@admin.register(AuditCallback)
-class AuditCallbackAdmin(admin.ModelAdmin):
- list_display = [
- "id",
- "bucket_region",
- "bucket_id",
- "event_name",
- "audit_id",
- "is_sensitive",
- "creation_time",
- "callback_time",
- "updated_time",
- "is_handled",
- ]
- list_filter = ["bucket_region", "bucket_id", "event_name", "is_sensitive", "is_handled"]
- list_editable = ["is_handled"]
diff --git a/apps/tcloud/apps.py b/apps/tcloud/apps.py
deleted file mode 100644
index ca3d266..0000000
--- a/apps/tcloud/apps.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from django.apps import AppConfig
-from django.utils.translation import gettext_lazy
-
-
-class TCloudConfig(AppConfig):
- default_auto_field = "django.db.models.BigAutoField"
- name = "apps.tcloud"
- verbose_name = gettext_lazy("TCloud")
diff --git a/apps/tcloud/constants.py b/apps/tcloud/constants.py
deleted file mode 100644
index e104c95..0000000
--- a/apps/tcloud/constants.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from django.utils.translation import gettext_lazy
-from ovinc_client.core.models import TextChoices
-
-
-class CICallbackEventName(TextChoices):
- REVIEW_IMAGE = "ReviewImage", gettext_lazy("Review Image")
- REVIEW_DOCUMENT = "ReviewDocument", gettext_lazy("Review Document")
- REVIEW_VIDEO = "ReviewVideo", gettext_lazy("Review Video")
- REVIEW_AUDIO = "ReviewAudio", gettext_lazy("Review Audio")
- REVIEW_TEXT = "ReviewText", gettext_lazy("Review Text")
- REVIEW_HTML = "ReviewHtml", gettext_lazy("Review HTML")
diff --git a/apps/tcloud/migrations/0001_initial.py b/apps/tcloud/migrations/0001_initial.py
deleted file mode 100644
index 3cfd79a..0000000
--- a/apps/tcloud/migrations/0001_initial.py
+++ /dev/null
@@ -1,50 +0,0 @@
-# pylint: disable=C0301,C0103
-# Generated by Django 4.2.16 on 2024-11-29 12:00
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
- initial = True
-
- dependencies = []
-
- operations = [
- migrations.CreateModel(
- name="AuditCallback",
- fields=[
- ("id", models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID")),
- ("bucket_region", models.CharField(max_length=32, verbose_name="Bucket Region")),
- ("bucket_id", models.CharField(db_index=True, max_length=255, verbose_name="Bucket ID")),
- (
- "event_name",
- models.CharField(
- choices=[
- ("ReviewImage", "Review Image"),
- ("ReviewDocument", "Review Document"),
- ("ReviewVideo", "Review Video"),
- ("ReviewAudio", "Review Audio"),
- ("ReviewText", "Review Text"),
- ("ReviewHtml", "Review HTML"),
- ],
- db_index=True,
- max_length=32,
- verbose_name="Event Name",
- ),
- ),
- ("audit_id", models.CharField(db_index=True, max_length=255, verbose_name="Audit ID")),
- ("is_sensitive", models.BooleanField(db_index=True, verbose_name="Result")),
- ("detail", models.JSONField(verbose_name="Detail")),
- ("creation_time", models.DateTimeField(db_index=True, verbose_name="Audit Time")),
- ("callback_time", models.DateTimeField(auto_now_add=True, db_index=True, verbose_name="Callback Time")),
- ("updated_time", models.DateTimeField(auto_now=True, db_index=True, verbose_name="Updated Time")),
- ("is_handled", models.BooleanField(db_index=True, default=False, verbose_name="Handled")),
- ],
- options={
- "verbose_name": "Audit Callback",
- "verbose_name_plural": "Audit Callback",
- "ordering": ["-id"],
- "unique_together": {("bucket_region", "bucket_id", "event_name", "audit_id")},
- },
- ),
- ]
diff --git a/apps/tcloud/migrations/0002_alter_auditcallback_creation_time.py b/apps/tcloud/migrations/0002_alter_auditcallback_creation_time.py
deleted file mode 100644
index c98d36e..0000000
--- a/apps/tcloud/migrations/0002_alter_auditcallback_creation_time.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# pylint: disable=C0301,C0103
-# Generated by Django 4.2.16 on 2024-11-29 13:05
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
- dependencies = [
- ("tcloud", "0001_initial"),
- ]
-
- operations = [
- migrations.AlterField(
- model_name="auditcallback",
- name="creation_time",
- field=models.CharField(blank=True, db_index=True, max_length=32, null=True, verbose_name="Audit Time"),
- ),
- ]
diff --git a/apps/tcloud/migrations/__init__.py b/apps/tcloud/migrations/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/tcloud/models.py b/apps/tcloud/models.py
deleted file mode 100644
index 08ea0a5..0000000
--- a/apps/tcloud/models.py
+++ /dev/null
@@ -1,62 +0,0 @@
-from django.contrib.auth import get_user_model
-from django.db import models
-from django.utils.translation import gettext_lazy
-from ovinc_client.core.constants import MAX_CHAR_LENGTH, SHORT_CHAR_LENGTH
-from ovinc_client.core.models import BaseModel
-
-from apps.tcloud.constants import CICallbackEventName
-from apps.tcloud.parsers import TCICallbackParser, TCIDocumentCallbackParser
-
-USER_MODEL = get_user_model()
-
-
-class AuditCallback(BaseModel):
- """
- Audit Callback
- """
-
- id = models.BigAutoField(verbose_name=gettext_lazy("ID"), primary_key=True)
- bucket_region = models.CharField(verbose_name=gettext_lazy("Bucket Region"), max_length=SHORT_CHAR_LENGTH)
- bucket_id = models.CharField(verbose_name=gettext_lazy("Bucket ID"), max_length=MAX_CHAR_LENGTH, db_index=True)
- event_name = models.CharField(
- verbose_name=gettext_lazy("Event Name"),
- max_length=SHORT_CHAR_LENGTH,
- db_index=True,
- choices=CICallbackEventName.choices,
- )
- audit_id = models.CharField(verbose_name=gettext_lazy("Audit ID"), max_length=MAX_CHAR_LENGTH, db_index=True)
- is_sensitive = models.BooleanField(verbose_name=gettext_lazy("Is Sensitive"), db_index=True)
- detail = models.JSONField(verbose_name=gettext_lazy("Detail"))
- creation_time = models.CharField(
- verbose_name=gettext_lazy("Audit Time"), max_length=SHORT_CHAR_LENGTH, db_index=True, null=True, blank=True
- )
- callback_time = models.DateTimeField(verbose_name=gettext_lazy("Callback Time"), db_index=True, auto_now_add=True)
- updated_time = models.DateTimeField(verbose_name=gettext_lazy("Updated Time"), db_index=True, auto_now=True)
- is_handled = models.BooleanField(verbose_name=gettext_lazy("Handled"), db_index=True, default=False)
-
- class Meta:
- verbose_name = gettext_lazy("Audit Callback")
- verbose_name_plural = verbose_name
- ordering = ["-id"]
- unique_together = [["bucket_region", "bucket_id", "event_name", "audit_id"]]
-
- def __str__(self):
- return f"{self.bucket_region}:{self.bucket_id}:{self.event_name}:{self.audit_id}"
-
- @classmethod
- def add_callback(cls, data: dict) -> "AuditCallback":
- event_name = data["EventName"]
- match event_name:
- case CICallbackEventName.REVIEW_DOCUMENT:
- parser = TCIDocumentCallbackParser(data)
- case _:
- parser = TCICallbackParser(data)
- return cls.objects.create(
- bucket_region=parser.bucket_region,
- bucket_id=parser.bucket_id,
- event_name=event_name,
- audit_id=parser.audit_id,
- is_sensitive=parser.is_sensitive,
- detail=parser.detail,
- creation_time=parser.creation_time,
- )
diff --git a/apps/tcloud/parsers.py b/apps/tcloud/parsers.py
deleted file mode 100644
index eba89cc..0000000
--- a/apps/tcloud/parsers.py
+++ /dev/null
@@ -1,41 +0,0 @@
-class TCICallbackParser:
- """
- callback parser
- """
-
- def __init__(self, data: dict):
- self.data = data
-
- @property
- def is_sensitive(self) -> bool:
- return self.data["JobsDetail"]["Result"] != 0
-
- @property
- def audit_id(self) -> str:
- return self.data["JobsDetail"]["JobId"]
-
- @property
- def detail(self) -> dict:
- return self.data
-
- @property
- def bucket_id(self) -> str:
- return self.data["JobsDetail"]["BucketId"]
-
- @property
- def bucket_region(self) -> str:
- return self.data["JobsDetail"]["Region"]
-
- @property
- def creation_time(self) -> str:
- return self.data["JobsDetail"]["CreationTime"]
-
-
-class TCIDocumentCallbackParser(TCICallbackParser):
- """
- document callback parser
- """
-
- @property
- def is_sensitive(self) -> bool:
- return self.data["JobsDetail"]["Suggestion"] != 0
diff --git a/apps/tcloud/serializers.py b/apps/tcloud/serializers.py
deleted file mode 100644
index f85614b..0000000
--- a/apps/tcloud/serializers.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from rest_framework import serializers
-
-from apps.tcloud.constants import CICallbackEventName
-
-
-class TCICallbackSerializer(serializers.Serializer):
- EventName = serializers.ChoiceField(choices=CICallbackEventName.choices)
- JobsDetail = serializers.JSONField()
diff --git a/apps/tcloud/urls.py b/apps/tcloud/urls.py
deleted file mode 100644
index 085b288..0000000
--- a/apps/tcloud/urls.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from django.conf import settings
-from django.urls import include, path
-from rest_framework.routers import SimpleRouter
-
-from apps.tcloud.views import AuditCallbackViewSet
-
-router = SimpleRouter()
-router.register("", AuditCallbackViewSet, basename="audit_callback")
-
-urlpatterns = [path(f"ci/callback/{settings.TCI_AUDIT_CALLBACK_PREFIX}", include(router.urls))]
diff --git a/apps/tcloud/views.py b/apps/tcloud/views.py
deleted file mode 100644
index 693a166..0000000
--- a/apps/tcloud/views.py
+++ /dev/null
@@ -1,56 +0,0 @@
-from django.conf import settings
-from django.utils.translation import gettext
-from ovinc_client.core.auth import SessionAuthenticate
-from ovinc_client.core.viewsets import MainViewSet
-from rest_framework.request import Request
-from rest_framework.response import Response
-
-from apps.cel.tasks import send_notice
-from apps.notice.constants import NoticeWayChoices
-from apps.tcloud.models import AuditCallback
-from apps.tcloud.serializers import TCICallbackSerializer
-
-
-class AuditCallbackViewSet(MainViewSet):
- """
- callback view set
- """
-
- authentication_classes = [SessionAuthenticate]
-
- def create(self, request: Request, *args, **kwargs) -> Response:
- """
- callback
- """
- # validate
- req_slz = TCICallbackSerializer(data=request.data)
- req_slz.is_valid(raise_exception=True)
- # save
- callback = AuditCallback.add_callback(req_slz.validated_data)
- if callback.is_sensitive:
- send_notice.delay(
- notice_type=NoticeWayChoices.ROBOT,
- robots=[settings.NOTICE_AUDIT_SENSITIVE_ROBOT],
- content={
- "msgtype": "markdown",
- "markdown": {
- "content": gettext(
- 'Sensitive Object Notification\n'
- "Bucket Region: %(region)s\n"
- "Bucket ID: %(bucket)s\n"
- "Event Name: %(event)s\n"
- "Audit ID: %(audit_id)s\n"
- "Creation Time: %(creation_time)s\n"
- )
- % {
- "region": callback.bucket_region,
- "bucket": callback.bucket_id,
- "event": callback.get_event_name_display(),
- "audit_id": callback.audit_id,
- "creation_time": callback.creation_time,
- }
- },
- },
- )
- # response
- return Response()
diff --git a/bin/run.sh b/bin/run.sh
deleted file mode 100755
index 0080b8d..0000000
--- a/bin/run.sh
+++ /dev/null
@@ -1,7 +0,0 @@
-#! /bin/sh
-
-python manage.py collectstatic --noinput
-python manage.py migrate --noinput
-nohup python manage.py celery worker -c ${WORKER_COUNT:-1} -l INFO >/dev/stdout 2>&1 &
-nohup python manage.py celery beat -l INFO >/dev/stdout 2>&1 &
-gunicorn --bind "[::]:8020" -w ${WEB_PROCESSES:-1} --threads ${WEB_THREADS:-10} -k uvicorn_worker.UvicornWorker --proxy-protocol --proxy-allow-from "*" --forwarded-allow-ips "*" entry.asgi:application
diff --git a/core/__init__.py b/core/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/core/auth.py b/core/auth.py
deleted file mode 100644
index d793a2d..0000000
--- a/core/auth.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from django.contrib.auth import get_user_model
-from django.utils.translation import gettext
-from rest_framework.authentication import BaseAuthentication
-from rest_framework.request import Request
-
-from apps.application.models import Application
-from core.constants import (
- APP_AUTH_HEADER_APPID_KEY,
- APP_AUTH_HEADER_APPID_NONCE,
- APP_AUTH_HEADER_APPID_SIGN,
- APP_AUTH_HEADER_APPID_TIMESTAMP,
-)
-from core.exceptions import AppAuthFailed
-
-USER_MODEL = get_user_model()
-
-
-class ApplicationAuthenticate(BaseAuthentication):
- """
- Application Authenticate
- """
-
- # pylint: disable=W0236
- def authenticate(self, request: Request) -> (Application, None):
- # load params
- app_code = request.headers.get(APP_AUTH_HEADER_APPID_KEY)
- signature = request.headers.get(APP_AUTH_HEADER_APPID_SIGN)
- timestamp = request.headers.get(APP_AUTH_HEADER_APPID_TIMESTAMP)
- nonce = request.headers.get(APP_AUTH_HEADER_APPID_NONCE)
- if not app_code or not signature or not timestamp or not nonce:
- raise AppAuthFailed(gettext("App Auth Headers Not Exist"))
- # varify app
- try:
- app = Application.objects.get(pk=app_code)
- except Application.DoesNotExist as err: # pylint: disable=E1101
- raise AppAuthFailed(gettext("App Not Exist")) from err
- # verify secret
- if app.check_sign(signature=signature, timestamp=timestamp, nonce=nonce):
- return app, None
- raise AppAuthFailed(gettext("Signature Invalid"))
diff --git a/core/constants.py b/core/constants.py
deleted file mode 100644
index fe8aac7..0000000
--- a/core/constants.py
+++ /dev/null
@@ -1,8 +0,0 @@
-APP_AUTH_HEADER_APPID_KEY = "ovinc-appid"
-APP_AUTH_HEADER_APPID_SIGN = "ovinc-sign"
-APP_AUTH_HEADER_APPID_TIMESTAMP = "ovinc-timestamp"
-APP_AUTH_HEADER_APPID_NONCE = "ovinc-nonce"
-
-APP_AUTH_NONCE_CACHE_KEY = "ovinc-app-auth:nonce:{appid}:{nonce}"
-
-WECHAT_USER_AGENT_KEY = "micromessenger"
diff --git a/core/exceptions.py b/core/exceptions.py
deleted file mode 100644
index 6a69872..0000000
--- a/core/exceptions.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from django.utils.translation import gettext_lazy
-from rest_framework import status
-from rest_framework.exceptions import APIException
-
-
-class AuthTokenInvalid(APIException):
- status_code = status.HTTP_400_BAD_REQUEST
- default_detail = gettext_lazy("Auth Token Invalid")
-
-
-class AppAuthFailed(APIException):
- status_code = status.HTTP_403_FORBIDDEN
- default_detail = gettext_lazy("App Auth Failed")
diff --git a/core/utils.py b/core/utils.py
deleted file mode 100644
index 69810f2..0000000
--- a/core/utils.py
+++ /dev/null
@@ -1,7 +0,0 @@
-from rest_framework.request import Request
-
-from core.constants import WECHAT_USER_AGENT_KEY
-
-
-def is_wechat(request: Request) -> bool:
- return str(request.headers.get("User-Agent") or "").lower().find(WECHAT_USER_AGENT_KEY) != -1
diff --git a/entry/__init__.py b/entry/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/entry/asgi.py b/entry/asgi.py
deleted file mode 100644
index 8d74061..0000000
--- a/entry/asgi.py
+++ /dev/null
@@ -1,13 +0,0 @@
-import os
-
-from channels.routing import ProtocolTypeRouter
-from django.core.asgi import get_asgi_application
-from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
-
-os.environ.setdefault("DJANGO_SETTINGS_MODULE", "entry.settings")
-
-application = ProtocolTypeRouter(
- {
- "http": OpenTelemetryMiddleware(get_asgi_application()),
- }
-)
diff --git a/entry/settings.py b/entry/settings.py
deleted file mode 100644
index a8c1fde..0000000
--- a/entry/settings.py
+++ /dev/null
@@ -1,264 +0,0 @@
-import os
-from pathlib import Path
-
-import pymysql
-from environ.environ import Env
-from ovinc_client.core.logger import get_logging_config_dict
-from ovinc_client.core.utils import getenv_or_raise, strtobool
-
-pymysql.install_as_MySQLdb()
-
-# Base Dir
-BASE_DIR = Path(__file__).resolve().parent.parent
-
-# Env
-env = Env()
-env.read_env(os.path.join(BASE_DIR, ".env"))
-
-# DEBUG
-DEBUG = strtobool(os.getenv("DEBUG", "False"))
-
-# APP_CODE & SECRET
-APP_CODE = getenv_or_raise("APP_CODE")
-APP_SECRET = getenv_or_raise("APP_SECRET")
-SECRET_KEY = getenv_or_raise("APP_SECRET")
-
-# Hosts
-BACKEND_URL = getenv_or_raise("BACKEND_URL")
-ALLOWED_HOSTS = getenv_or_raise("ALLOWED_HOSTS").split(",")
-CORS_ALLOW_CREDENTIALS = strtobool(os.getenv("CORS_ALLOW_CREDENTIALS", "True"))
-CORS_ORIGIN_WHITELIST = getenv_or_raise("CORS_ORIGIN_WHITELIST").split(",")
-CSRF_TRUSTED_ORIGINS = CORS_ORIGIN_WHITELIST
-FRONTEND_URL = getenv_or_raise("FRONTEND_URL")
-
-# APPs
-INSTALLED_APPS = [
- "daphne",
- "simpleui",
- "corsheaders",
- "django.contrib.auth",
- "django.contrib.admin",
- "django.contrib.contenttypes",
- "django.contrib.sessions",
- "django.contrib.messages",
- "django.contrib.staticfiles",
- "rest_framework",
- "sslserver",
- "oidc_provider",
- "apps.account",
- "apps.application",
- "apps.cel",
- "apps.home",
- "apps.notice",
- "apps.tcloud",
- "ovinc_client.tcaptcha",
- "ovinc_client.trace",
-]
-
-# MIDDLEWARE
-MIDDLEWARE = [
- "corsheaders.middleware.CorsMiddleware",
- "ovinc_client.core.middlewares.CSRFExemptMiddleware",
- "django.middleware.security.SecurityMiddleware",
- "django.contrib.sessions.middleware.SessionMiddleware",
- "django.contrib.auth.middleware.AuthenticationMiddleware",
- "django.middleware.common.CommonMiddleware",
- "django.contrib.messages.middleware.MessageMiddleware",
- "django.middleware.clickjacking.XFrameOptionsMiddleware",
- "django.middleware.locale.LocaleMiddleware",
- "ovinc_client.core.middlewares.SQLDebugMiddleware",
-]
-if not DEBUG:
- MIDDLEWARE += ["ovinc_client.core.middlewares.UnHandleExceptionMiddleware"]
-
-# Urls
-ROOT_URLCONF = "entry.urls"
-
-# TEMPLATES
-TEMPLATES = [
- {
- "BACKEND": "django.template.backends.django.DjangoTemplates",
- "DIRS": [],
- "APP_DIRS": True,
- "OPTIONS": {
- "context_processors": [
- "django.template.context_processors.debug",
- "django.template.context_processors.request",
- "django.contrib.auth.context_processors.auth",
- "django.contrib.messages.context_processors.messages",
- ],
- },
- },
-]
-
-# DB and Cache
-DATABASES = {
- "default": {
- "ENGINE": "django.db.backends.mysql",
- "NAME": getenv_or_raise("DB_NAME"),
- "USER": getenv_or_raise("DB_USER"),
- "PASSWORD": getenv_or_raise("DB_PASSWORD"),
- "HOST": getenv_or_raise("DB_HOST"),
- "PORT": int(getenv_or_raise("DB_PORT")),
- "CONN_MAX_AGE": int(os.getenv("DB_CONN_MAX_AGE", str(60 * 60))),
- "OPTIONS": {"charset": "utf8mb4"},
- }
-}
-DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
-REDIS_HOST = getenv_or_raise("REDIS_HOST")
-REDIS_PORT = int(getenv_or_raise("REDIS_PORT"))
-REDIS_PASSWORD = getenv_or_raise("REDIS_PASSWORD")
-REDIS_DB = int(getenv_or_raise("REDIS_DB"))
-CACHES = {
- "default": {
- "BACKEND": "django_redis.cache.RedisCache",
- "LOCATION": f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}",
- }
-}
-
-# ASGI
-ASGI_APPLICATION = "entry.asgi.application"
-CHANNEL_LAYERS = {
- "default": {
- "BACKEND": "channels_redis.core.RedisChannelLayer",
- "CONFIG": {
- "hosts": [
- f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}",
- ],
- },
- },
-}
-
-# Auth
-AUTH_PASSWORD_VALIDATORS = [
- {
- "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
- },
- {
- "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
- },
- {
- "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
- },
- {
- "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
- },
-]
-
-# International
-LANGUAGE_CODE = os.getenv("DEFAULT_LANGUAGE", "zh-hans")
-TIME_ZONE = os.getenv("DEFAULT_TIME_ZONE", "Asia/Shanghai")
-USE_I18N = True
-USE_L10N = True
-USE_TZ = True
-LANGUAGES = (("zh-hans", "中文简体"), ("en", "English"))
-LOCALE_PATHS = (os.path.join(BASE_DIR, "locale"),)
-
-# Static
-STATIC_URL = "/static/"
-STATIC_ROOT = os.path.join(BASE_DIR, "static")
-STATICFILES_DIRS = [os.path.join(BASE_DIR, "staticfiles")]
-
-# Session
-SESSION_COOKIE_NAME = os.getenv("SESSION_COOKIE_NAME", f"{'dev-' if DEBUG else ''}{APP_CODE}-sessionid")
-SESSION_ENGINE = "django.contrib.sessions.backends.cache"
-SESSION_CACHE_ALIAS = "default"
-SESSION_COOKIE_AGE = int(os.getenv("SESSION_COOKIE_AGE", str(60 * 60 * 24 * 7)))
-SESSION_COOKIE_DOMAIN = os.getenv("SESSION_COOKIE_DOMAIN")
-
-# Log
-LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
-LOG_FORMAT = os.getenv("LOG_FORMAT", "json")
-LOGGING = get_logging_config_dict(log_level=LOG_LEVEL, log_format=LOG_FORMAT)
-
-# rest_framework
-REST_FRAMEWORK = {
- "DEFAULT_RENDERER_CLASSES": ["ovinc_client.core.renderers.APIRenderer"],
- "DEFAULT_PAGINATION_CLASS": "ovinc_client.core.paginations.NumPagination",
- "DATETIME_FORMAT": "%Y-%m-%dT%H:%M%z",
- "DEFAULT_THROTTLE_RATES": {
- "phone_verify_code": "1/m",
- "ip": "60/m",
- "app": "20/s",
- },
- "EXCEPTION_HANDLER": "ovinc_client.core.exceptions.exception_handler",
- "UNAUTHENTICATED_USER": "apps.account.models.CustomAnonymousUser",
- "DEFAULT_AUTHENTICATION_CLASSES": ["ovinc_client.core.auth.LoginRequiredAuthenticate"],
-}
-
-# User
-AUTH_USER_MODEL = "account.User"
-
-# App
-APP_AUTH_SIGN_EXPIRE = int(os.getenv("APP_AUTH_SIGN_EXPIRE", "60"))
-
-# Celery
-CELERY_TIMEZONE = TIME_ZONE
-CELERY_ENABLE_UTC = True
-CELERY_TASK_TRACK_STARTED = True
-CELERY_TASK_TIME_LIMIT = 30 * 60
-CELERY_ACCEPT_CONTENT = ["pickle", "json"]
-CELERY_WORKER_HIJACK_ROOT_LOGGER = False
-BROKER_URL = f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}"
-
-# APM
-ENABLE_TRACE = strtobool(os.getenv("ENABLE_TRACE", "False"))
-SERVICE_NAME = os.getenv("SERVICE_NAME", APP_CODE)
-OTLP_HOST = os.getenv("OTLP_HOST", "http://127.0.0.1:4317")
-OTLP_TOKEN = os.getenv("OTLP_TOKEN", "")
-
-# RUM
-RUM_ID = os.getenv("RUM_ID", "")
-RUM_HOST = os.getenv("RUM_HOST", "https://rumt-zh.com")
-
-# QCloud
-QCLOUD_SECRET_ID = os.getenv("QCLOUD_SECRET_ID")
-QCLOUD_SECRET_KEY = os.getenv("QCLOUD_SECRET_KEY")
-
-# Notice
-NOTICE_MAIL_HOST = os.getenv("NOTICE_MAIL_HOST")
-NOTICE_MAIL_PORT = int(os.getenv("NOTICE_MAIL_PORT", "465"))
-NOTICE_MAIL_USERNAME = os.getenv("NOTICE_MAIL_USERNAME")
-NOTICE_MAIL_PASSWORD = os.getenv("NOTICE_MAIL_PASSWORD")
-NOTICE_MSG_TCLOUD_ID = os.getenv("NOTICE_MSG_TCLOUD_ID", QCLOUD_SECRET_ID)
-NOTICE_MSG_TCLOUD_KEY = os.getenv("NOTICE_MSG_TCLOUD_KEY", QCLOUD_SECRET_KEY)
-NOTICE_MSG_TCLUD_REGION = os.getenv("NOTICE_MSG_TCLUD_REGION", "ap-guangzhou")
-NOTICE_MSG_TCLOUD_APP = os.getenv("NOTICE_MSG_TCLOUD_APP")
-NOTICE_MSG_TCLOUD_SIGN = os.getenv("NOTICE_MSG_TCLOUD_SIGN")
-NOTICE_ROBOT_CONTENT_HELP = os.getenv(
- "NOTICE_ROBOT_CONTENT_HELP", "https://developer.work.weixin.qq.com/document/path/91770"
-)
-NOTICE_SMS_ID_VERIFY_CODE = os.getenv("NOTICE_SMS_ID_VERIFY_CODE", "")
-NOTICE_SMS_ID_VERIFY_CODE_GLOBAL = os.getenv("NOTICE_SMS_ID_VERIFY_CODE_GLOBAL", "")
-NOTICE_AUDIT_SENSITIVE_ROBOT = os.getenv("NOTICE_AUDIT_SENSITIVE_ROBOT", "")
-
-# TCI Callback
-TCI_AUDIT_CALLBACK_PREFIX = os.getenv("TCI_AUDIT_CALLBACK_PREFIX", "")
-
-# OAuth
-OAUTH_CODE_TIMEOUT = int(os.getenv("OAUTH_CODE_TIMEOUT", str(60 * 5)))
-
-# WeChat
-WECHAT_APP_ID = os.getenv("WECHAT_APP_ID")
-WECHAT_APP_KEY = os.getenv("WECHAT_APP_KEY")
-MP_WECHAT_APP_ID = os.getenv("MP_WECHAT_APP_ID")
-MP_WECHAT_APP_KEY = os.getenv("MP_WECHAT_APP_KEY")
-WECHAT_SCOPE_TIMEOUT = int(os.getenv("WECHAT_SCOPE_TIMEOUT", str(60 * 30)))
-WECHAT_OAUTH_API = os.getenv("WECHAT_OAUTH_API", "https://api.weixin.qq.com/sns/oauth2")
-WECHAT_USER_INFO_API = os.getenv("WECHAT_OAUTH_API", "https://api.weixin.qq.com/sns/userinfo")
-
-# Captcha
-CAPTCHA_TCLOUD_ID = os.getenv("CAPTCHA_TCLOUD_ID", QCLOUD_SECRET_ID)
-CAPTCHA_TCLOUD_KEY = os.getenv("CAPTCHA_TCLOUD_KEY", QCLOUD_SECRET_KEY)
-CAPTCHA_ENABLED = strtobool(os.getenv("CAPTCHA_ENABLED", "False"))
-CAPTCHA_APP_ID = int(os.getenv("CAPTCHA_APP_ID", "0"))
-CAPTCHA_APP_SECRET = os.getenv("CAPTCHA_APP_SECRET", "")
-CAPTCHA_APP_INFO_TIMEOUT = int(os.getenv("CAPTCHA_APP_INFO_TIMEOUT", str(60 * 10)))
-
-# OIDC
-OIDC_USERINFO = "apps.account.utils.userinfo"
-OIDC_IDTOKEN_SUB_GENERATOR = "apps.account.utils.default_sub_generator"
-OIDC_IDTOKEN_INCLUDE_CLAIMS = strtobool(os.getenv("OIDC_IDTOKEN_INCLUDE_CLAIMS", "True"))
-OIDC_LOGIN_URL = "/account/oidc_login/"
-OIDC_CODE_EXPIRE = int(os.getenv("OIDC_CODE_EXPIRE", "600"))
-OIDC_IDTOKEN_EXPIRE = int(os.getenv("OIDC_IDTOKEN_EXPIRE", "600"))
diff --git a/entry/urls.py b/entry/urls.py
deleted file mode 100644
index 63eec1e..0000000
--- a/entry/urls.py
+++ /dev/null
@@ -1,37 +0,0 @@
-from urllib.parse import quote
-
-from django.conf import settings
-from django.contrib import admin
-from django.contrib.staticfiles.views import serve
-from django.urls import include, path, re_path
-from django.views.generic import RedirectView
-from ovinc_client.core import exceptions
-
-
-# pylint: disable=W0621
-def serve_static(request, path, insecure=True, **kwargs):
- return serve(request, path, insecure=True, **kwargs)
-
-
-ADMIN_PAGE_URL = f"{settings.BACKEND_URL}/admin/"
-ADMIN_PAGE_LOGIN_URL = f"{settings.FRONTEND_URL}/login/?next={quote(ADMIN_PAGE_URL)}"
-
-urlpatterns = [
- path("favicon.ico", RedirectView.as_view(url=f"{settings.FRONTEND_URL}/favicon.ico")),
- re_path(r"^static/(?P.*)$", serve_static, name="static"),
- path("admin/login/", RedirectView.as_view(url=ADMIN_PAGE_LOGIN_URL.replace("%", "%%"))),
- path("admin/", admin.site.urls),
- path("openid/", include("oidc_provider.urls", namespace="oidc_provider")),
- path("", include("apps.home.urls")),
- path("account/", include("apps.account.urls")),
- path("application/", include("apps.application.urls")),
- path("notice/", include("apps.notice.urls")),
- path("tcloud/", include("apps.tcloud.urls")),
- path("trace/", include("ovinc_client.trace.urls")),
- path("tcaptcha/", include("ovinc_client.tcaptcha.urls")),
-]
-
-handler400 = exceptions.bad_request
-handler403 = exceptions.permission_denied
-handler404 = exceptions.page_not_found
-handler500 = exceptions.server_error
diff --git a/env.example b/env.example
deleted file mode 100644
index 8bc01f6..0000000
--- a/env.example
+++ /dev/null
@@ -1,17 +0,0 @@
-APP_CODE=union-api
-APP_SECRET=union-api
-BACKEND_URL=https://dev-api.example.com
-ALLOWED_HOSTS=dev-api.example.com
-FRONTEND_URL=https://dev.example.com
-CORS_ORIGIN_WHITELIST=https://dev.example.com
-DB_NAME=union-api
-DB_USER=union-api
-DB_PASSWORD=
-DB_HOST=127.0.0.1
-DB_PORT=3306
-REDIS_HOST=127.0.0.1
-REDIS_PORT=6379
-REDIS_PASSWORD=
-REDIS_DB=0
-OVINC_API_DOMAIN=https://api.example.com
-OVINC_WEB_URL=https://www.example.com
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..17c1afb
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,110 @@
+module github.com/ovinc-cn/apicenter/v2
+
+go 1.24.0
+
+require (
+ github.com/BurntSushi/toml v1.5.0
+ github.com/gin-gonic/gin v1.11.0
+ github.com/google/uuid v1.6.0
+ github.com/hibiken/asynq v0.25.1
+ github.com/joho/godotenv v1.5.1
+ github.com/nicksnyder/go-i18n/v2 v2.6.0
+ github.com/redis/go-redis/v9 v9.7.3
+ github.com/spf13/cobra v1.10.1
+ github.com/swaggo/files v1.0.1
+ github.com/swaggo/gin-swagger v1.6.1
+ github.com/swaggo/swag v1.16.6
+ go.opentelemetry.io/otel v1.38.0
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0
+ go.opentelemetry.io/otel/sdk v1.38.0
+ go.opentelemetry.io/otel/trace v1.38.0
+ go.uber.org/zap v1.27.0
+ golang.org/x/crypto v0.41.0
+ golang.org/x/text v0.29.0
+ gorm.io/driver/mysql v1.6.0
+ gorm.io/gorm v1.31.0
+ gorm.io/plugin/opentelemetry v0.1.16
+)
+
+require (
+ filippo.io/edwards25519 v1.1.0 // indirect
+ github.com/ClickHouse/ch-go v0.61.5 // indirect
+ github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
+ github.com/KyleBanks/depth v1.2.1 // indirect
+ github.com/PuerkitoBio/purell v1.1.1 // indirect
+ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
+ github.com/andybalholm/brotli v1.1.1 // indirect
+ github.com/bytedance/sonic v1.14.0 // indirect
+ github.com/bytedance/sonic/loader v0.3.0 // indirect
+ github.com/cenkalti/backoff/v5 v5.0.3 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/cloudwego/base64x v0.1.6 // indirect
+ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
+ github.com/gabriel-vasile/mimetype v1.4.8 // indirect
+ github.com/gin-contrib/sse v1.1.0 // indirect
+ github.com/go-faster/city v1.0.1 // indirect
+ github.com/go-faster/errors v0.7.1 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/go-openapi/jsonpointer v0.19.5 // indirect
+ github.com/go-openapi/jsonreference v0.19.6 // indirect
+ github.com/go-openapi/spec v0.20.4 // indirect
+ github.com/go-openapi/swag v0.22.3 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-playground/validator/v10 v10.27.0 // indirect
+ github.com/go-sql-driver/mysql v1.9.3 // indirect
+ github.com/goccy/go-json v0.10.2 // indirect
+ github.com/goccy/go-yaml v1.18.0 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
+ github.com/hashicorp/go-version v1.6.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
+ github.com/jackc/pgx/v5 v5.5.5 // indirect
+ github.com/jackc/puddle/v2 v2.2.1 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/josharian/intern v1.0.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/compress v1.17.8 // indirect
+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
+ github.com/mailru/easyjson v0.7.7 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/paulmach/orb v0.11.1 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+ github.com/pierrec/lz4/v4 v4.1.21 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/quic-go/qpack v0.5.1 // indirect
+ github.com/quic-go/quic-go v0.54.0 // indirect
+ github.com/robfig/cron/v3 v3.0.1 // indirect
+ github.com/segmentio/asm v1.2.0 // indirect
+ github.com/shopspring/decimal v1.4.0 // indirect
+ github.com/spf13/cast v1.7.0 // indirect
+ github.com/spf13/pflag v1.0.9 // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/ugorji/go/codec v1.3.0 // indirect
+ go.opentelemetry.io/auto/sdk v1.1.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
+ go.opentelemetry.io/otel/metric v1.38.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.7.1 // indirect
+ go.uber.org/mock v0.5.0 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ golang.org/x/arch v0.20.0 // indirect
+ golang.org/x/mod v0.27.0 // indirect
+ golang.org/x/net v0.43.0 // indirect
+ golang.org/x/sync v0.17.0 // indirect
+ golang.org/x/sys v0.35.0 // indirect
+ golang.org/x/time v0.8.0 // indirect
+ golang.org/x/tools v0.36.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect
+ google.golang.org/grpc v1.75.0 // indirect
+ google.golang.org/protobuf v1.36.9 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ gorm.io/driver/clickhouse v0.7.0 // indirect
+ gorm.io/driver/postgres v1.5.11 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..0397a61
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,347 @@
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
+github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/ClickHouse/ch-go v0.61.5 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4=
+github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg=
+github.com/ClickHouse/clickhouse-go/v2 v2.30.0 h1:AG4D/hW39qa58+JHQIFOSnxyL46H6h2lrmGGk17dhFo=
+github.com/ClickHouse/clickhouse-go/v2 v2.30.0/go.mod h1:i9ZQAojcayW3RsdCb3YR+n+wC2h65eJsZCscZ1Z1wyo=
+github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
+github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
+github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
+github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
+github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
+github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
+github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
+github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
+github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
+github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
+github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
+github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
+github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
+github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
+github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
+github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
+github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
+github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
+github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
+github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
+github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
+github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
+github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
+github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
+github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
+github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
+github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
+github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
+github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
+github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
+github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
+github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
+github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
+github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
+github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
+github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
+github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
+github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
+github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
+github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
+github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
+github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
+github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
+github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
+github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
+github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
+github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw=
+github.com/hibiken/asynq v0.25.1/go.mod h1:pazWNOLBu0FEynQRBvHA26qdIKRSmfdIfUm4HdsLmXg=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
+github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
+github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
+github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
+github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
+github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
+github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ=
+github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
+github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
+github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
+github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
+github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
+github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
+github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
+github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
+github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
+github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
+github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
+github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
+github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
+github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
+github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
+github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
+github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
+github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
+github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
+github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
+github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
+github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
+github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
+github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
+github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
+github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
+github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
+github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
+github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
+github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
+github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
+go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
+go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
+go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
+go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk=
+go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
+go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
+go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
+go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
+go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
+go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
+go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
+go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
+go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
+go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
+go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
+go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
+golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
+golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
+golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
+golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
+golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
+golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
+golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
+golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
+gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
+google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
+google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc=
+google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
+google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
+google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/clickhouse v0.7.0 h1:BCrqvgONayvZRgtuA6hdya+eAW5P2QVagV3OlEp1vtA=
+gorm.io/driver/clickhouse v0.7.0/go.mod h1:TmNo0wcVTsD4BBObiRnCahUgHJHjBIwuRejHwYt3JRs=
+gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
+gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
+gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
+gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
+gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
+gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
+gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc=
+gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y=
diff --git a/internal/apps/account/accountRouters/sign_in.go b/internal/apps/account/accountRouters/sign_in.go
new file mode 100644
index 0000000..fb4f8c7
--- /dev/null
+++ b/internal/apps/account/accountRouters/sign_in.go
@@ -0,0 +1,55 @@
+package accountRouters
+
+import (
+ "fmt"
+
+ "github.com/gin-gonic/gin"
+ "github.com/ovinc-cn/apicenter/v2/internal/apps/account"
+ "github.com/ovinc-cn/apicenter/v2/pkg/apiMixin"
+ "github.com/ovinc-cn/apicenter/v2/pkg/logger"
+)
+
+type SignInRequest struct {
+ Username string `json:"username" binding:"required,min=4,max=32"`
+ Password string `json:"password" binding:"required,min=64,max=64"`
+}
+
+// SignIn godoc
+//
+// @Tags Account
+// @Param request body SignInRequest true "request body"
+// @Produce json
+// @Success 200 {object} apiMixin.ResponseModel
+// @Router /v1/account/sign-in [post]
+func SignIn(c *gin.Context) {
+ // init
+ ctx := apiMixin.GetTraceCtx(c)
+
+ // req
+ var req SignInRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ apiMixin.ResponseBadRequest(c, err.Error())
+ return
+ }
+
+ // load user from db
+ var user account.User
+ if err := user.ExactByUsernameAndPassword(ctx, req.Username, req.Password); err != nil {
+ logger.Logger(ctx).Error(fmt.Sprintf("[SignIn] load user from db failed\n%s", err))
+ apiMixin.ResponseBadRequest(c, apiMixin.Translate(c, "account.loginFailed", nil))
+ return
+ }
+
+ // set up cookie
+ token, err := user.MakeNewToken(ctx)
+ if err != nil {
+ logger.Logger(ctx).Error(fmt.Sprintf("[SignUp] make token failed\n%s", err.Error()))
+ apiMixin.ResponseInternalError(c)
+ return
+ }
+ c.SetCookie(user.CookieName(), token, user.CacheKeyTimeout(), "", user.CookieDomain(), user.CookieSecure(), user.CookieHTTPOnly())
+
+ // response
+ apiMixin.ResponseSuccess(c, nil)
+ return
+}
diff --git a/internal/apps/account/accountRouters/sign_out.go b/internal/apps/account/accountRouters/sign_out.go
new file mode 100644
index 0000000..33fe647
--- /dev/null
+++ b/internal/apps/account/accountRouters/sign_out.go
@@ -0,0 +1,50 @@
+package accountRouters
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/ovinc-cn/apicenter/v2/internal/apps/account"
+ "github.com/ovinc-cn/apicenter/v2/pkg/apiMixin"
+ "github.com/ovinc-cn/apicenter/v2/pkg/logger"
+ "github.com/ovinc-cn/apicenter/v2/pkg/redis"
+)
+
+// SignOut godoc
+//
+// @Tags Account
+// @Produce json
+// @Success 200 {object} apiMixin.ResponseModel
+// @Router /v1/account/sign-out [get]
+func SignOut(c *gin.Context) {
+ // init
+ ctx := apiMixin.GetTraceCtx(c)
+
+ // load cookie
+ user := &account.User{}
+ cookie, err := c.Cookie(user.CookieName())
+ if err != nil {
+ if errors.Is(err, http.ErrNoCookie) {
+ apiMixin.ResponseSuccess(c, nil)
+ return
+ }
+ logger.Logger(ctx).Error(fmt.Sprintf("[SignOut] load cookie failed\n%s", err.Error()))
+ apiMixin.ResponseInternalError(c)
+ return
+ }
+
+ // delete redis cache
+ if _, err := redis.Del(ctx, redis.Client(), user.TokenCacheKey(cookie)); err != nil {
+ logger.Logger(ctx).Error(fmt.Sprintf("[SignOut] delete redis cache failed\n%s", err.Error()))
+ apiMixin.ResponseInternalError(c)
+ return
+ }
+
+ // delete cookie
+ c.SetCookie(user.CookieName(), "", -1, "", user.CookieDomain(), user.CookieSecure(), user.CookieHTTPOnly())
+
+ // response
+ apiMixin.ResponseSuccess(c, nil)
+}
diff --git a/internal/apps/account/accountRouters/sign_up.go b/internal/apps/account/accountRouters/sign_up.go
new file mode 100644
index 0000000..78d98a2
--- /dev/null
+++ b/internal/apps/account/accountRouters/sign_up.go
@@ -0,0 +1,115 @@
+package accountRouters
+
+import (
+ "database/sql"
+ "fmt"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/ovinc-cn/apicenter/v2/internal/apps/account"
+ "github.com/ovinc-cn/apicenter/v2/internal/config"
+ "github.com/ovinc-cn/apicenter/v2/pkg/apiMixin"
+ "github.com/ovinc-cn/apicenter/v2/pkg/logger"
+ "github.com/ovinc-cn/apicenter/v2/pkg/mysql"
+ "github.com/ovinc-cn/apicenter/v2/pkg/password"
+)
+
+type SignUpRequest struct {
+ SignInRequest
+ NickName string `json:"nickname" binding:"min=4,max=32"`
+ PhoneNumber string `json:"phone_number" binding:"required,min=11,max=11"`
+}
+
+// SignUp godoc
+//
+// @Tags Account
+// @Param request body SignUpRequest true "request body"
+// @Produce json
+// @Success 200 {object} apiMixin.ResponseModel
+// @Router /v1/account/sign-up [post]
+func SignUp(c *gin.Context) {
+ // trace
+ ctx := apiMixin.GetTraceCtx(c)
+
+ // req
+ var req SignUpRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ apiMixin.ResponseBadRequest(c, err.Error())
+ return
+ }
+
+ // check username pattern
+ if !account.UsernameRegex.MatchString(req.Username) {
+ apiMixin.ResponseBadRequest(c, apiMixin.Translate(c, "account.usernameInvalid", nil))
+ return
+ }
+
+ // check username exists
+ var userCount int64
+ if err := mysql.Count(
+ ctx,
+ mysql.DB().Model(&account.User{}).Where("username = ?", req.Username).Or("nick_name = ?", req.NickName),
+ &userCount,
+ ); err != nil {
+ logger.Logger(ctx).Error(fmt.Sprintf("[SignUp] check username duplicate failed\n%s", err.Error()))
+ apiMixin.ResponseInternalError(c)
+ return
+ }
+ if userCount > 0 {
+ apiMixin.ResponseBadRequest(c, apiMixin.Translate(c, "account.usernameDuplicate", nil))
+ return
+ }
+
+ // check phone number exists
+ var phoneCount int64
+ if err := mysql.Count(
+ ctx,
+ mysql.DB().Model(&account.User{}).Where("phone_number = ?", req.PhoneNumber),
+ &phoneCount,
+ ); err != nil {
+ logger.Logger(ctx).Error(fmt.Sprintf("[SignUp] check phone number duplicate failed\n%s", err.Error()))
+ apiMixin.ResponseInternalError(c)
+ return
+ }
+ if phoneCount > 0 {
+ apiMixin.ResponseBadRequest(c, apiMixin.Translate(c, "account.phoneNumberDuplicate", nil))
+ return
+ }
+
+ // make password
+ hashedPassword, err := password.MakePassword(req.Password)
+ if err != nil {
+ logger.Logger(ctx).Error(fmt.Sprintf("[SignUp] hash password failed\n%s", err.Error()))
+ apiMixin.ResponseInternalError(c)
+ return
+ }
+
+ // insert into db
+ user := account.User{
+ Username: req.Username,
+ NickName: sql.NullString{String: req.NickName, Valid: true},
+ Password: hashedPassword,
+ DateJoined: time.Now(),
+ LastLogin: time.Now(),
+ PhoneNumber: sql.NullString{String: req.PhoneNumber, Valid: true},
+ EmailAddress: sql.NullString{String: fmt.Sprintf("%s@%s", req.Username, config.Config.AppAccount.EmailDomain), Valid: true},
+ }
+ if err := mysql.Create(ctx, mysql.DB(), &user); err != nil {
+ logger.Logger(ctx).Error(fmt.Sprintf("[SignUp] insert user into db failed\n%s", err.Error()))
+ apiMixin.ResponseInternalError(c)
+ return
+ }
+
+ // set up cookie
+ token, err := user.MakeNewToken(ctx)
+ if err != nil {
+ logger.Logger(ctx).Error(fmt.Sprintf("[SignUp] make token failed\n%s", err.Error()))
+ apiMixin.ResponseInternalError(c)
+ return
+ }
+ c.SetCookie(user.CookieName(), token, user.CacheKeyTimeout(), "", user.CookieDomain(), user.CookieSecure(), user.CookieHTTPOnly())
+
+ // response
+ apiMixin.ResponseSuccess(c, nil)
+ return
+}
diff --git a/internal/apps/account/accountRouters/user_info.go b/internal/apps/account/accountRouters/user_info.go
new file mode 100644
index 0000000..d7aa7e4
--- /dev/null
+++ b/internal/apps/account/accountRouters/user_info.go
@@ -0,0 +1,48 @@
+package accountRouters
+
+import (
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/ovinc-cn/apicenter/v2/internal/apps/account"
+ "github.com/ovinc-cn/apicenter/v2/pkg/apiMixin"
+ "github.com/ovinc-cn/apicenter/v2/pkg/cfg"
+)
+
+type UserInfoData struct {
+ UserID uint64 `json:"user_id"`
+ Username string `json:"username"`
+ NickName string `json:"nick_name"`
+ LastLogin string `json:"last_login"`
+}
+
+type UserInfoResponse struct {
+ apiMixin.ResponseModel
+ Data UserInfoData `json:"data"`
+}
+
+// UserInfo godoc
+//
+// @Tags Account
+// @Produce json
+// @Success 200 {object} UserInfoResponse
+// @Router /v1/account/user-info [get]
+func UserInfo(c *gin.Context) {
+ // load user from context
+ var user account.User
+ if err := apiMixin.GetUser(c, &user); err != nil {
+ apiMixin.ResponseInternalError(c)
+ return
+ }
+
+ // response
+ apiMixin.ResponseSuccess(
+ c,
+ UserInfoData{
+ UserID: user.ID,
+ Username: user.Username,
+ NickName: user.GetNickName(),
+ LastLogin: user.LastLogin.In(cfg.AppTimezone()).Format(time.RFC3339),
+ },
+ )
+}
diff --git a/internal/apps/account/constants.go b/internal/apps/account/constants.go
new file mode 100644
index 0000000..e33d783
--- /dev/null
+++ b/internal/apps/account/constants.go
@@ -0,0 +1,7 @@
+package account
+
+import "regexp"
+
+var (
+ UsernameRegex = regexp.MustCompile(`^[a-zA-Z0-9]{4,32}$`)
+)
diff --git a/internal/apps/account/middlewares.go b/internal/apps/account/middlewares.go
new file mode 100644
index 0000000..443bd65
--- /dev/null
+++ b/internal/apps/account/middlewares.go
@@ -0,0 +1,55 @@
+package account
+
+import (
+ "fmt"
+
+ "github.com/gin-gonic/gin"
+ "github.com/ovinc-cn/apicenter/v2/pkg/apiMixin"
+ "github.com/ovinc-cn/apicenter/v2/pkg/logger"
+ "github.com/ovinc-cn/apicenter/v2/pkg/trace"
+ "go.opentelemetry.io/otel/attribute"
+)
+
+func LoginRequiredMiddleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // init
+ ctx, span := trace.StartSpan(apiMixin.GetTraceCtx(c), "LoginRequiredMiddleware", trace.SpanKindInternal)
+ defer span.End()
+
+ // user
+ user := &User{}
+
+ // get cookie
+ cookie, err := c.Cookie(user.CookieName())
+ if err != nil || cookie == "" {
+ apiMixin.ResponseUnauthorized(c)
+ return
+ }
+
+ // validate token
+ if err = user.ValidateToken(ctx, cookie); err != nil {
+ logger.Logger(ctx).Info(fmt.Sprintf("[LoginRequiredMiddleware] validate token failed\n%v", err))
+ apiMixin.ResponseUnauthorized(c)
+ return
+ }
+
+ //set user to context
+ apiMixin.SetUsername(c, user.Username)
+ apiMixin.SetUserID(c, user.ID)
+ if err := apiMixin.SetUser(c, user); err != nil {
+ logger.Logger(ctx).Error(fmt.Sprintf("[LoginRequiredMiddleware] set user to context failed\n%v", err))
+ apiMixin.ResponseInternalError(c)
+ return
+ }
+
+ // trace
+ span.SetAttributes(
+ attribute.String("username", user.Username),
+ attribute.String("user_id", fmt.Sprintf("%d", user.ID)),
+ )
+ span.End()
+
+ // next
+ c.Next()
+ }
+}
diff --git a/internal/apps/account/models.go b/internal/apps/account/models.go
new file mode 100644
index 0000000..3553f2b
--- /dev/null
+++ b/internal/apps/account/models.go
@@ -0,0 +1,122 @@
+package account
+
+import (
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/ovinc-cn/apicenter/v2/internal/config"
+ "github.com/ovinc-cn/apicenter/v2/pkg/mysql"
+ "github.com/ovinc-cn/apicenter/v2/pkg/password"
+ "github.com/ovinc-cn/apicenter/v2/pkg/redis"
+)
+
+type User struct {
+ ID uint64 `json:"id" gorm:"primaryKey;autoIncrement;"`
+ Username string `json:"username" gorm:"type:varchar(32);unique;"`
+ NickName sql.NullString `json:"nick_name" gorm:"type:varchar(32);default:null;unique;"`
+ Password string `json:"password" gorm:"type:varchar(255);"`
+ DateJoined time.Time `json:"date_joined" gorm:"type:datetime;autoCreateTime;"`
+ LastLogin time.Time `json:"last_login" gorm:"type:datetime;"`
+ PhoneNumber sql.NullString `json:"phone_number" gorm:"type:varchar(32);default:null;unique;"`
+ EmailAddress sql.NullString `json:"email_address" gorm:"type:varchar(255);default:null;unique;"`
+ WeChatOpenID sql.NullString `json:"wechat_open_id" gorm:"column:wechat_open_id;type:varchar(255);default:null;unique;"`
+ WeChatUnionID sql.NullString `json:"wechat_union_id" gorm:"column:wechat_union_id;type:varchar(255);default:null;unique;"`
+ Avatar sql.NullString `json:"avatar" gorm:"type:varchar(255);default:null;"`
+}
+
+func (u *User) ExactByUsername(ctx context.Context, username string) error {
+ if err := mysql.Select(ctx, mysql.DB(), u, "username = ?", username); err != nil {
+ return err
+ }
+ if u.ID == 0 {
+ return fmt.Errorf("user not found: %s", username)
+ }
+ return nil
+}
+
+func (u *User) ExactByUsernameAndPassword(ctx context.Context, username, passWd string) error {
+ // load user from db
+ if err := u.ExactByUsername(ctx, username); err != nil {
+ return err
+ }
+ // check password
+ isMatch, err := password.CheckPassword(passWd, u.Password)
+ if err != nil {
+ return err
+ }
+ if !isMatch {
+ return fmt.Errorf("password not match")
+ }
+ // update last login time
+ u.LastLogin = time.Now()
+ if err := mysql.Update(ctx, mysql.DB(), u, "last_login", time.Now().UTC()); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (u *User) MakeNewToken(ctx context.Context) (string, error) {
+ h := sha256.New()
+ h.Write([]byte(fmt.Sprintf("%d", time.Now().UnixMilli())))
+ token := fmt.Sprintf("%x#%s", h.Sum(nil), uuid.NewString())
+ if _, err := redis.Set(ctx, redis.Client(), u.TokenCacheKey(token), u.Username, time.Duration(u.CacheKeyTimeout())*time.Second); err != nil {
+ return "", err
+ }
+ return token, nil
+}
+
+func (u *User) ValidateToken(ctx context.Context, token string) error {
+ // load from cache
+ username, err := redis.Get(ctx, redis.Client(), u.TokenCacheKey(token))
+ if err != nil {
+ return err
+ }
+ if username == "" {
+ return fmt.Errorf("invalid token: %s", token)
+ }
+
+ // load user from db
+ return u.ExactByUsername(ctx, username)
+}
+
+func (u *User) TokenCacheKey(token string) string {
+ return fmt.Sprintf("account:user:token:%s", token)
+}
+
+func (u *User) CacheKeyTimeout() int {
+ return config.Config.AppAccount.AuthTokenTimeout
+}
+
+func (u *User) CookieName() string {
+ return config.Config.AppAccount.AuthTokenCookieName
+}
+
+func (u *User) CookieDomain() string {
+ return config.Config.API.CookieDomain
+}
+
+func (u *User) CookieSecure() bool {
+ return config.Config.API.CookieSecure
+}
+
+func (u *User) CookieHTTPOnly() bool {
+ return config.Config.API.CookieHTTPOnly
+}
+
+func (u *User) GetNickName() string {
+ if u.NickName.Valid {
+ return u.NickName.String
+ }
+ return ""
+}
+
+func (u *User) GetEmail() string {
+ if u.EmailAddress.Valid {
+ return u.EmailAddress.String
+ }
+ return ""
+}
diff --git a/internal/apps/home/homeRouters/healthz.go b/internal/apps/home/homeRouters/healthz.go
new file mode 100644
index 0000000..856e3d3
--- /dev/null
+++ b/internal/apps/home/homeRouters/healthz.go
@@ -0,0 +1,44 @@
+package homeRouters
+
+import (
+ "fmt"
+
+ "github.com/gin-gonic/gin"
+ "github.com/ovinc-cn/apicenter/v2/pkg/apiMixin"
+ "github.com/ovinc-cn/apicenter/v2/pkg/logger"
+ "github.com/ovinc-cn/apicenter/v2/pkg/mysql"
+ "github.com/ovinc-cn/apicenter/v2/pkg/redis"
+)
+
+// HealthZ godoc
+//
+// @Tags Home
+// @Produce json
+// @Success 200 {object} apiMixin.ResponseModel
+// @Router /v1/healthz [get]
+func HealthZ(c *gin.Context) {
+ // trace
+ ctx := apiMixin.GetTraceCtx(c)
+
+ // check for db
+ sqlDB, err := mysql.DB().DB()
+ if err != nil {
+ logger.Logger(ctx).Error(fmt.Sprintf("[HealthZ] get sql db failed\n%s", err.Error()))
+ apiMixin.ResponseInternalError(c)
+ return
+ }
+ if err := sqlDB.Ping(); err != nil {
+ logger.Logger(ctx).Error(fmt.Sprintf("[HealthZ] ping mysql failed\n%s", err.Error()))
+ apiMixin.ResponseInternalError(c)
+ return
+ }
+
+ // check for redis
+ if err := redis.Ping(ctx, redis.Client()); err != nil {
+ logger.Logger(ctx).Error(fmt.Sprintf("[HealthZ] ping redis failed\n%s", err.Error()))
+ apiMixin.ResponseInternalError(c)
+ return
+ }
+
+ apiMixin.ResponseSuccess(c, nil)
+}
diff --git a/internal/apps/home/homeRouters/i18n.go b/internal/apps/home/homeRouters/i18n.go
new file mode 100644
index 0000000..ccdc6a2
--- /dev/null
+++ b/internal/apps/home/homeRouters/i18n.go
@@ -0,0 +1,39 @@
+package homeRouters
+
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/ovinc-cn/apicenter/v2/internal/config"
+ "github.com/ovinc-cn/apicenter/v2/pkg/apiMixin"
+)
+
+type ChangeLangRequest struct {
+ Lang string `json:"lang" binding:"required,oneof=zh-Hans en"`
+}
+
+// ChangeLang godoc
+//
+// @Tags Home
+// @Param request body ChangeLangRequest true "request body"
+// @Produce json
+// @Success 200 {object} apiMixin.ResponseModel
+// @Router /v1/language [put]
+func ChangeLang(c *gin.Context) {
+ // req
+ var req ChangeLangRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ apiMixin.ResponseBadRequest(c, err.Error())
+ return
+ }
+
+ // set cookie
+ c.SetCookie(
+ apiMixin.I18nLangCookieKey,
+ req.Lang,
+ apiMixin.I18nLangCookieTTL,
+ "",
+ config.Config.API.CookieDomain,
+ config.Config.API.CookieSecure,
+ config.Config.API.CookieHTTPOnly,
+ )
+ apiMixin.ResponseSuccess(c, nil)
+}
diff --git a/internal/apps/home/tasks.go b/internal/apps/home/tasks.go
new file mode 100644
index 0000000..3411d31
--- /dev/null
+++ b/internal/apps/home/tasks.go
@@ -0,0 +1,31 @@
+package home
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/hibiken/asynq"
+ "github.com/ovinc-cn/apicenter/v2/pkg/redis"
+ "github.com/ovinc-cn/apicenter/v2/pkg/trace"
+ "go.opentelemetry.io/otel/codes"
+)
+
+var DebugTaskLockKey = "task:home:debug"
+var DebugTaskLockTTL = 1 * time.Hour
+
+func DebugTask(ctx context.Context, t *asynq.Task) error {
+ // trace
+ ctx, span := trace.StartSpan(ctx, fmt.Sprintf("Task#%s", t.Type()), trace.SpanKindConsumer)
+ defer span.End()
+
+ // lock
+ lock := redis.NewLock(ctx, redis.Client(), DebugTaskLockKey, DebugTaskLockTTL)
+ defer lock.Release()
+ if err := lock.Lock(); err != nil {
+ span.SetStatus(codes.Error, err.Error())
+ return err
+ }
+
+ return nil
+}
diff --git a/internal/cmd/api.go b/internal/cmd/api.go
new file mode 100644
index 0000000..8d1cdc0
--- /dev/null
+++ b/internal/cmd/api.go
@@ -0,0 +1,14 @@
+package cmd
+
+import (
+ "github.com/ovinc-cn/apicenter/v2/internal/httpd"
+ "github.com/spf13/cobra"
+)
+
+var apiCmd = &cobra.Command{
+ Use: "api",
+ Short: "Api Center HttpServer",
+ Run: func(cmd *cobra.Command, args []string) {
+ httpd.Serve()
+ },
+}
diff --git a/internal/cmd/beat.go b/internal/cmd/beat.go
new file mode 100644
index 0000000..dbbb8b8
--- /dev/null
+++ b/internal/cmd/beat.go
@@ -0,0 +1,14 @@
+package cmd
+
+import (
+ "github.com/ovinc-cn/apicenter/v2/internal/crond/beat"
+ "github.com/spf13/cobra"
+)
+
+var beatCmd = &cobra.Command{
+ Use: "beat",
+ Short: "Api Center Scheduler",
+ Run: func(cmd *cobra.Command, args []string) {
+ beat.Serve()
+ },
+}
diff --git a/internal/cmd/migrate.go b/internal/cmd/migrate.go
new file mode 100644
index 0000000..d2160b6
--- /dev/null
+++ b/internal/cmd/migrate.go
@@ -0,0 +1,20 @@
+package cmd
+
+import (
+ "log"
+
+ "github.com/ovinc-cn/apicenter/v2/internal/apps/account"
+ "github.com/ovinc-cn/apicenter/v2/pkg/mysql"
+ "github.com/spf13/cobra"
+)
+
+var migrateCmd = &cobra.Command{
+ Use: "migrator",
+ Short: "Api Center Migrator",
+ Run: func(cmd *cobra.Command, args []string) {
+ if err := mysql.DB().AutoMigrate(&account.User{}); err != nil {
+ log.Fatalf("[Migrate] auto migrate failed; %s", err)
+ }
+ log.Println("[Migrate] auto migrate success")
+ },
+}
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
new file mode 100644
index 0000000..fa1aefa
--- /dev/null
+++ b/internal/cmd/root.go
@@ -0,0 +1,53 @@
+package cmd
+
+import (
+ "log"
+ "os"
+
+ "github.com/ovinc-cn/apicenter/v2/internal/crond"
+ "github.com/ovinc-cn/apicenter/v2/internal/version"
+ "github.com/ovinc-cn/apicenter/v2/pkg/trace"
+ "github.com/spf13/cobra"
+)
+
+var rootCmd = &cobra.Command{
+ Use: "api-center",
+ Short: "API Center",
+ Version: version.Version,
+ Run: func(cmd *cobra.Command, args []string) {
+ if len(args) > 0 && cmd.CalledAs() == "api-center" {
+ appMode := args[0]
+ args = args[1:]
+ switch appMode {
+ case "api":
+ apiCmd.Run(apiCmd, args)
+ case "worker":
+ workerCmd.Run(workerCmd, args)
+ case "beat":
+ beatCmd.Run(beatCmd, args)
+ case "migrate":
+ migrateCmd.Run(migrateCmd, args)
+ default:
+ log.Fatal("[CMD] unknown app mode")
+ }
+ } else {
+ log.Fatal("[CMD] please specify app mode")
+ }
+ },
+ PostRun: func(cmd *cobra.Command, args []string) {
+ _ = crond.AsyncClient.Close()
+ trace.OnExit()
+ os.Exit(0)
+ },
+}
+
+func init() {
+ rootCmd.CompletionOptions.DisableDefaultCmd = true
+ rootCmd.SetVersionTemplate(`{{printf "API Center "}}{{printf "%s" .Version}}`)
+}
+
+func Execute() {
+ if err := rootCmd.Execute(); err != nil {
+ log.Fatalf("[CMD] execute failed; %s", err)
+ }
+}
diff --git a/internal/cmd/worker.go b/internal/cmd/worker.go
new file mode 100644
index 0000000..d1941c0
--- /dev/null
+++ b/internal/cmd/worker.go
@@ -0,0 +1,14 @@
+package cmd
+
+import (
+ "github.com/ovinc-cn/apicenter/v2/internal/crond/worker"
+ "github.com/spf13/cobra"
+)
+
+var workerCmd = &cobra.Command{
+ Use: "worker",
+ Short: "Api Center Worker",
+ Run: func(cmd *cobra.Command, args []string) {
+ worker.Serve()
+ },
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..4c36f8c
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,30 @@
+package config
+
+import (
+ "time"
+
+ "github.com/ovinc-cn/apicenter/v2/pkg/cfg"
+)
+
+var Config configModel
+
+func init() {
+ Config = configModel{
+ API: apiConfig{
+ Addr: cfg.GetConfigVal("APP_API_ADDR", "0.0.0.0:8000"),
+ CookieDomain: cfg.GetConfigVal("APP_API_COOKIE_DOMAIN", ""),
+ CookieSecure: cfg.GetConfigBool("APP_API_COOKIE_SECURE", true),
+ CookieHTTPOnly: cfg.GetConfigBool("APP_API_COOKIE_HTTP_ONLY", true),
+ },
+ Cron: cronConfig{
+ WorkerConcurrency: cfg.GetConfigInt("APP_CRON_WORKER_CONCURRENCY", 1),
+ WorkerQueues: cfg.GetConfigStruct("APP_CRON_WORKER_QUEUES", `{"default": 1}`, make(map[string]int)),
+ WorkerShutDownTimeout: time.Duration(cfg.GetConfigInt64("APP_CRON_WORKER_SHUTDOWN_TIMEOUT", 60)) * time.Second,
+ },
+ AppAccount: appAccountConfig{
+ EmailDomain: cfg.GetConfigVal("APP_ACCOUNT_EMAIL_DOMAIN", "example.com"),
+ AuthTokenTimeout: cfg.GetConfigInt("APP_ACCOUNT_AUTH_TOKEN_TIMEOUT", 7*24*60*60),
+ AuthTokenCookieName: cfg.GetConfigVal("APP_ACCOUNT_AUTH_TOKEN_COOKIE_NAME", "auth-token"),
+ },
+ }
+}
diff --git a/internal/config/models.go b/internal/config/models.go
new file mode 100644
index 0000000..93906a1
--- /dev/null
+++ b/internal/config/models.go
@@ -0,0 +1,28 @@
+package config
+
+import "time"
+
+type apiConfig struct {
+ Addr string
+ CookieDomain string
+ CookieSecure bool
+ CookieHTTPOnly bool
+}
+
+type cronConfig struct {
+ WorkerConcurrency int
+ WorkerQueues map[string]int
+ WorkerShutDownTimeout time.Duration
+}
+
+type appAccountConfig struct {
+ EmailDomain string
+ AuthTokenTimeout int
+ AuthTokenCookieName string
+}
+
+type configModel struct {
+ API apiConfig
+ Cron cronConfig
+ AppAccount appAccountConfig
+}
diff --git a/internal/crond/beat/beat.go b/internal/crond/beat/beat.go
new file mode 100644
index 0000000..a78c00a
--- /dev/null
+++ b/internal/crond/beat/beat.go
@@ -0,0 +1,47 @@
+package beat
+
+import (
+ "context"
+ "log"
+
+ "github.com/hibiken/asynq"
+ "github.com/ovinc-cn/apicenter/v2/internal/crond"
+ "github.com/ovinc-cn/apicenter/v2/pkg/cfg"
+)
+
+type taskConfig struct {
+ TaskType crond.TaskType
+ Data interface{}
+ CronSpec string
+ Ops []asynq.Option
+}
+
+var periodTasks = []taskConfig{
+ {
+ TaskType: crond.TaskTypeDebug,
+ CronSpec: "* * * * *",
+ },
+}
+
+func Serve() {
+ // init beat
+ scheduler := asynq.NewScheduler(crond.RedisClientOption, &asynq.SchedulerOpts{Location: cfg.AppTimezone()})
+
+ // register task
+ for _, periodTask := range periodTasks {
+ // register task
+ task, err := crond.NewTask(context.Background(), periodTask.TaskType, periodTask.Data, periodTask.Ops...)
+ if err != nil {
+ log.Fatalf("[Beat] create %s task failed: %s", periodTask.TaskType, err)
+ }
+ if _, err = scheduler.Register(periodTask.CronSpec, task); err != nil {
+ log.Fatalf("[Beat] register %s task failed: %s", periodTask.TaskType, err)
+ }
+ log.Printf("[Beat] register %s task success", periodTask.TaskType)
+ }
+
+ // start
+ if err := scheduler.Run(); err != nil {
+ log.Fatalf("[Beat] start beat failed: %s", err)
+ }
+}
diff --git a/internal/crond/constants.go b/internal/crond/constants.go
new file mode 100644
index 0000000..1f51718
--- /dev/null
+++ b/internal/crond/constants.go
@@ -0,0 +1,31 @@
+package crond
+
+import (
+ "github.com/hibiken/asynq"
+)
+
+type TaskType = string
+
+const (
+ TaskTypeDebug TaskType = "DebugTask"
+)
+
+type QueueType = string
+
+const (
+ QueueTypeDefault QueueType = "default"
+)
+
+func getQueue(t TaskType) QueueType {
+ switch t {
+ default:
+ return QueueTypeDefault
+ }
+}
+
+func getOps(t TaskType) []asynq.Option {
+ switch t {
+ default:
+ return []asynq.Option{}
+ }
+}
diff --git a/internal/crond/crond.go b/internal/crond/crond.go
new file mode 100644
index 0000000..b4ce8d8
--- /dev/null
+++ b/internal/crond/crond.go
@@ -0,0 +1,20 @@
+package crond
+
+import (
+ "log"
+
+ "github.com/hibiken/asynq"
+ "github.com/ovinc-cn/apicenter/v2/pkg/cfg"
+)
+
+var AsyncClient *asynq.Client
+var RedisClientOption = asynq.RedisClientOpt{
+ Addr: cfg.RedisAddr(),
+ Password: cfg.RedisPassword(),
+ DB: cfg.RedisDB(),
+}
+
+func init() {
+ AsyncClient = asynq.NewClient(RedisClientOption)
+ log.Printf("[AsyncClient] init success\n")
+}
diff --git a/internal/crond/utils.go b/internal/crond/utils.go
new file mode 100644
index 0000000..a90b94e
--- /dev/null
+++ b/internal/crond/utils.go
@@ -0,0 +1,72 @@
+package crond
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+
+ "github.com/hibiken/asynq"
+ "github.com/ovinc-cn/apicenter/v2/pkg/asyncTask"
+ "github.com/ovinc-cn/apicenter/v2/pkg/logger"
+ "github.com/ovinc-cn/apicenter/v2/pkg/trace"
+ "github.com/ovinc-cn/apicenter/v2/pkg/utils"
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/codes"
+ "go.opentelemetry.io/otel/propagation"
+)
+
+func NewTask(ctx context.Context, taskType TaskType, data interface{}, customOps ...asynq.Option) (*asynq.Task, error) {
+ // init trace
+ ctx, span := trace.StartSpan(ctx, fmt.Sprintf("NewTask#%s", taskType), trace.SpanKindInternal)
+ defer span.End()
+
+ // init payload
+ payload := asyncTask.TaskPayload{Carrier: propagation.MapCarrier{}, Data: data}
+
+ // inject carrier
+ otel.GetTextMapPropagator().Inject(ctx, payload.Carrier)
+
+ // build payload
+ payloadData, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+
+ // build options
+ ops := []asynq.Option{
+ asynq.Queue(getQueue(taskType)),
+ asynq.MaxRetry(0),
+ }
+ ops = append(ops, getOps(taskType)...)
+ ops = append(ops, customOps...)
+
+ // build task
+ return asynq.NewTask(taskType, payloadData, ops...), nil
+}
+
+func RunAsyncTask(ctx context.Context, taskType TaskType, data interface{}, ops ...asynq.Option) error {
+ // trace
+ ctx, span := trace.StartSpan(ctx, fmt.Sprintf("RunAsyncTask#%s", taskType), trace.SpanKindProducer)
+ defer span.End()
+
+ // init task
+ task, err := NewTask(ctx, taskType, data, ops...)
+ if err != nil {
+ return err
+ }
+
+ // run task
+ taskInfo, err := AsyncClient.Enqueue(task)
+ if err != nil {
+ if errors.Is(err, asynq.ErrDuplicateTask) {
+ logger.Logger(ctx).Warn(fmt.Sprintf("[RunAsyncTask] duplicate %s task", taskType))
+ return nil
+ }
+ span.SetStatus(codes.Error, err.Error())
+ logger.Logger(ctx).Error(fmt.Sprintf("[RunAsyncTask] enqueue %s task failed\nerr: %s", taskType, err))
+ return err
+ }
+ logger.Logger(ctx).Info(fmt.Sprintf("[RunAsyncTask] enqueue %s task success\ntask: %s", taskType, utils.ForceStringMaxLength(taskInfo)))
+ return nil
+}
diff --git a/internal/crond/worker/worker.go b/internal/crond/worker/worker.go
new file mode 100644
index 0000000..ff37f2f
--- /dev/null
+++ b/internal/crond/worker/worker.go
@@ -0,0 +1,35 @@
+package worker
+
+import (
+ "log"
+
+ "github.com/hibiken/asynq"
+ "github.com/ovinc-cn/apicenter/v2/internal/apps/home"
+ "github.com/ovinc-cn/apicenter/v2/internal/config"
+ "github.com/ovinc-cn/apicenter/v2/internal/crond"
+ "github.com/ovinc-cn/apicenter/v2/pkg/asyncTask"
+)
+
+func Serve() {
+ // init worker
+ srv := asynq.NewServer(
+ crond.RedisClientOption,
+ asynq.Config{
+ Concurrency: config.Config.Cron.WorkerConcurrency,
+ Queues: config.Config.Cron.WorkerQueues,
+ ShutdownTimeout: config.Config.Cron.WorkerShutDownTimeout,
+ },
+ )
+
+ // init
+ mux := asynq.NewServeMux()
+ mux.Use(asyncTask.RecoverMiddleware, asyncTask.ObserveMiddleware)
+
+ // home
+ mux.HandleFunc(crond.TaskTypeDebug, home.DebugTask)
+
+ // run worker
+ if err := srv.Run(mux); err != nil {
+ log.Fatalf("[Worker] start failed: %s", err)
+ }
+}
diff --git a/internal/httpd/httpd.go b/internal/httpd/httpd.go
new file mode 100644
index 0000000..c386574
--- /dev/null
+++ b/internal/httpd/httpd.go
@@ -0,0 +1,56 @@
+package httpd
+
+import (
+ "log"
+
+ "github.com/gin-gonic/gin"
+ "github.com/ovinc-cn/apicenter/v2/internal/apps/account"
+ "github.com/ovinc-cn/apicenter/v2/internal/apps/account/accountRouters"
+ "github.com/ovinc-cn/apicenter/v2/internal/apps/home/homeRouters"
+ "github.com/ovinc-cn/apicenter/v2/internal/config"
+ "github.com/ovinc-cn/apicenter/v2/pkg/apiMixin"
+ "github.com/ovinc-cn/apicenter/v2/pkg/cfg"
+ _ "github.com/ovinc-cn/apicenter/v2/support_files/api_docs"
+ "github.com/swaggo/files"
+ "github.com/swaggo/gin-swagger"
+)
+
+func Serve() {
+ // init gin
+ r := gin.New()
+
+ // middlewares
+ r.Use(apiMixin.RecoveryMiddleware(), apiMixin.ObserveMiddleware())
+
+ // routers
+ apiV1Group := r.Group("/v1")
+ {
+ // home
+ homeGroup := apiV1Group.Group("")
+ {
+ homeGroup.GET("/healthz", homeRouters.HealthZ)
+ homeGroup.PUT("/language", homeRouters.ChangeLang)
+ }
+ // account
+ accountGroup := apiV1Group.Group("/account")
+ {
+ accountGroup.POST("/sign-up", accountRouters.SignUp)
+ accountGroup.POST("/sign-in", accountRouters.SignIn)
+ accountGroup.GET("/sign-out", accountRouters.SignOut)
+ accountGroup.GET("/user-info", account.LoginRequiredMiddleware(), accountRouters.UserInfo)
+ }
+ }
+
+ // release mode
+ if cfg.AppDebug() {
+ r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
+ gin.SetMode(gin.DebugMode)
+ } else {
+ gin.SetMode(gin.ReleaseMode)
+ }
+
+ // start server
+ if err := r.Run(config.Config.API.Addr); err != nil {
+ log.Fatalf("[Http] server failed; %s", err)
+ }
+}
diff --git a/internal/version/VERSION b/internal/version/VERSION
new file mode 100644
index 0000000..359a5b9
--- /dev/null
+++ b/internal/version/VERSION
@@ -0,0 +1 @@
+2.0.0
\ No newline at end of file
diff --git a/internal/version/version.go b/internal/version/version.go
new file mode 100644
index 0000000..36ca974
--- /dev/null
+++ b/internal/version/version.go
@@ -0,0 +1,18 @@
+package version
+
+import (
+ _ "embed"
+ "log"
+ "strings"
+)
+
+//go:embed VERSION
+var Version string
+
+func init() {
+ Version = strings.TrimSpace(Version)
+ if Version == "" {
+ log.Fatal("[Version] version is empty")
+ }
+ log.Printf("[Version] %s\n", Version)
+}
diff --git a/locale/zh_Hans/LC_MESSAGES/django.po b/locale/zh_Hans/LC_MESSAGES/django.po
deleted file mode 100644
index f81f5d0..0000000
--- a/locale/zh_Hans/LC_MESSAGES/django.po
+++ /dev/null
@@ -1,443 +0,0 @@
-# SOME DESCRIPTIVE TITLE.
-# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
-# This file is distributed under the same license as the PACKAGE package.
-# FIRST AUTHOR , YEAR.
-#
-#, fuzzy
-msgid ""
-msgstr ""
-"Project-Id-Version: PACKAGE VERSION\n"
-"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2025-01-14 13:29+0800\n"
-"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
-"Last-Translator: FULL NAME \n"
-"Language-Team: LANGUAGE \n"
-"Language: \n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Content-Transfer-Encoding: 8bit\n"
-"Plural-Forms: nplurals=1; plural=0;\n"
-
-msgid "Is Expired"
-msgstr "是否过期"
-
-msgid "Yes"
-msgstr "是"
-
-msgid "No"
-msgstr "否"
-
-msgid "User Account"
-msgstr "用户账户"
-
-msgid "Personal"
-msgstr "个人"
-
-msgid "Platform"
-msgstr "平台"
-
-msgid "Code"
-msgstr "Code"
-
-msgid "Mainland China (+86)"
-msgstr "中国 (+86)"
-
-msgid "Hong Kong, China (+852)"
-msgstr "中国香港 (+852)"
-
-msgid "Macao, China (+853)"
-msgstr "中国澳门 (+853)"
-
-msgid "Taiwan, China (+886)"
-msgstr "中国台湾 (+886)"
-
-msgid "Sign In Params Not Exist"
-msgstr "登录参数缺失"
-
-msgid "User Not Exist"
-msgstr "用户不存在"
-
-msgid "Wrong Username or Password"
-msgstr "用户名或密码错误"
-
-msgid "Token Not Exist"
-msgstr "Token 不存在"
-
-msgid "Wrong Token"
-msgstr "Token 错误"
-
-msgid "State Invalid"
-msgstr "状态异常"
-
-msgid "WeChat Login Failed"
-msgstr "微信登录失败"
-
-msgid "Origin Password Invalid"
-msgstr "原密码不正确"
-
-msgid "Phone Verify Code Invalid"
-msgstr "电话验证码错误"
-
-msgid "Registry Disabled"
-msgstr "未开放注册"
-
-msgid "Username Cannot be Empty"
-msgstr "用户名不能为空"
-
-msgid "username"
-msgstr "用户名"
-
-msgid "already in use"
-msgstr "已被占用"
-
-msgid "Nick Name"
-msgstr "用户昵称"
-
-msgid "User Type"
-msgstr "用户类型"
-
-msgid "Date Joined"
-msgstr "注册时间"
-
-msgid "Phone Number"
-msgstr "联系电话"
-
-msgid "Email Address"
-msgstr "邮件地址"
-
-msgid "Wechat Open ID"
-msgstr "微信 Open ID"
-
-msgid "Wechat Union ID"
-msgstr "微信 Union ID"
-
-msgid "Avatar"
-msgstr "头像"
-
-msgid "Is Staff"
-msgstr "是否管理员"
-
-msgid "User"
-msgstr "用户"
-
-msgid "ID"
-msgstr "ID"
-
-msgid "Session Key"
-msgstr "会话键"
-
-msgid "Cache Key"
-msgstr "缓存键"
-
-msgid "Login IP"
-msgstr ""
-
-#, fuzzy
-#| msgid "User Account"
-msgid "User Agent"
-msgstr "用户账户"
-
-msgid "Expired at"
-msgstr "过期时间"
-
-msgid "User Token"
-msgstr "用户令牌"
-
-msgid "Username"
-msgstr "用户名"
-
-msgid "Password"
-msgstr "密码"
-
-msgid "Is OAuth"
-msgstr "是否 OAuth"
-
-msgid "WeChat Code"
-msgstr "微信 Code"
-
-msgid "Tencent Captcha"
-msgstr "腾讯云验证码"
-
-msgid "Phone Area"
-msgstr "电话归属国家"
-
-msgid "Phone Verify Code"
-msgstr "电话验证码"
-
-msgid "Username Invalid"
-msgstr "用户名不符合校验规则"
-
-msgid "Phone Number Already Exists"
-msgstr "电话已被使用"
-
-msgid "State"
-msgstr "状态"
-
-msgid "Application"
-msgstr "应用"
-
-msgid "App Name"
-msgstr "应用名称"
-
-msgid "App Code"
-msgstr "应用ID"
-
-msgid "App Secret"
-msgstr "应用密码"
-
-msgid "App Url"
-msgstr "应用访问地址"
-
-msgid "App Logo"
-msgstr "应用图标"
-
-msgid "App Desc"
-msgstr "应用描述"
-
-msgid "Is Hidden"
-msgstr "是否隐藏"
-
-msgid "Manager"
-msgstr "管理员"
-
-msgid "Application Manager"
-msgstr "应用管理员"
-
-msgid "Home Module"
-msgstr "首页"
-
-msgid "Language Code Invalid"
-msgstr "语言ID非法"
-
-msgid "Search IP Failed"
-msgstr "检索 IP 失败"
-
-msgid "Config Key"
-msgstr "配置键"
-
-msgid "Config Value"
-msgstr "配置值"
-
-msgid "Is Public"
-msgstr "是否公开"
-
-msgid "Meta Config"
-msgstr "基础配置"
-
-msgid "Meta Configs"
-msgstr "基础配置"
-
-msgid "Language Code"
-msgstr "语言ID"
-
-msgid "Notice"
-msgstr "通知"
-
-msgid "msg"
-msgstr "短信"
-
-msgid "mail"
-msgstr "邮件"
-
-msgid "robot"
-msgstr "机器人"
-
-msgid "Receivers"
-msgstr "接收人"
-
-msgid "Content"
-msgstr "内容"
-
-msgid "Extra Params"
-msgstr "参数"
-
-msgid "Result"
-msgstr "结果"
-
-msgid "Send At"
-msgstr "发送时间"
-
-msgid "Notice Log"
-msgstr "通知记录"
-
-msgid "Webhook"
-msgstr "回调地址"
-
-msgid "Name"
-msgstr "名称"
-
-msgid "Robot"
-msgstr "机器人"
-
-msgid "Username List"
-msgstr "用户名列表"
-
-msgid "Title"
-msgstr "标题"
-
-msgid "Template ID"
-msgstr "模板ID"
-
-msgid "Params"
-msgstr "参数"
-
-msgid "Robot ID"
-msgstr "机器人ID"
-
-#, python-format
-msgid "Notice Type not Exists => %s"
-msgstr "通知类型不存在 => %s"
-
-msgid "TCloud"
-msgstr "腾讯云"
-
-msgid "Review Image"
-msgstr "图片审核"
-
-msgid "Review Document"
-msgstr "文档审核"
-
-msgid "Review Video"
-msgstr "视频审核"
-
-msgid "Review Audio"
-msgstr "音频审核"
-
-msgid "Review Text"
-msgstr "文本审核"
-
-msgid "Review HTML"
-msgstr "网页审核"
-
-msgid "Bucket Region"
-msgstr "地域"
-
-msgid "Bucket ID"
-msgstr "存储桶ID"
-
-msgid "Event Name"
-msgstr "事件名"
-
-msgid "Audit ID"
-msgstr "审核ID"
-
-msgid "Is Sensitive"
-msgstr "敏感"
-
-msgid "Detail"
-msgstr "详情"
-
-msgid "Audit Time"
-msgstr "审核时间"
-
-msgid "Callback Time"
-msgstr "回调时间"
-
-msgid "Updated Time"
-msgstr "更新时间"
-
-msgid "Handled"
-msgstr "已处理"
-
-msgid "Audit Callback"
-msgstr "审核回调"
-
-#, python-format
-msgid ""
-"Sensitive Object Notification\n"
-"Bucket Region: %(region)s\n"
-"Bucket ID: %(bucket)s\n"
-"Event Name: %(event)s\n"
-"Audit ID: %(audit_id)s\n"
-"Creation Time: %(creation_time)s\n"
-msgstr ""
-"敏感对象通知\n"
-"地域: %(region)s\n"
-"存储桶: %(bucket)s\n"
-"事件: %(event)s\n"
-"审核ID: %(audit_id)s\n"
-"审核时间: %(creation_time)s\n"
-
-msgid "App Auth Headers Not Exist"
-msgstr "应用鉴权参数不存在"
-
-msgid "App Not Exist"
-msgstr "应用不存在"
-
-msgid "Signature Invalid"
-msgstr "签名非法"
-
-msgid "Auth Token Invalid"
-msgstr "认证 Token 非法"
-
-msgid "App Auth Failed"
-msgstr "应用鉴权失败"
-
-msgid "Mail Address"
-msgstr "邮件地址"
-
-msgid "Code Verify Failed"
-msgstr "代码验证失败"
-
-msgid "LIST"
-msgstr "LIST"
-
-msgid "CREATE"
-msgstr "CREATE"
-
-msgid "UPDATE"
-msgstr "UPDATE"
-
-msgid "PARTIAL_UPDATE"
-msgstr "PARTIAL_UPDATE"
-
-msgid "DESTROY"
-msgstr "DESTROY"
-
-msgid "Request Failed"
-msgstr "请求失败"
-
-msgid "Server Error"
-msgstr "服务器异常"
-
-msgid "Login Required"
-msgstr "未登录"
-
-msgid "Login Failed"
-msgstr "登录失败"
-
-msgid "Permission Denied"
-msgstr "未经授权"
-
-msgid "Operate Error"
-msgstr "异常操作"
-
-msgid "Resource Not Found"
-msgstr "资源未找到"
-
-msgid "Service Closed"
-msgstr "服务已关闭"
-
-msgid "Soft Delete"
-msgstr "软删除"
-
-msgid "Invalid Page Number"
-msgstr "页码错误"
-
-msgid "TCaptcha"
-msgstr "腾讯云验证码"
-
-msgid "Success"
-msgstr "成功"
-
-msgid "Low"
-msgstr "低"
-
-msgid "High"
-msgstr "高"
-
-msgid "TCaptcha Verify Failed"
-msgstr "验证码校验失败"
-
-msgid "App Performance"
-msgstr "应用性能"
diff --git a/locales/en.toml b/locales/en.toml
new file mode 100644
index 0000000..0d1530a
--- /dev/null
+++ b/locales/en.toml
@@ -0,0 +1,23 @@
+[apiMixin.internalServerError]
+other = "internal server error"
+
+[apiMixin.notFound]
+other = "resource not found"
+
+[apiMixin.noPermission]
+other = "no permission"
+
+[apiMixin.unAuthorized]
+other = "unauthorized"
+
+[account.usernameDuplicate]
+other = "username already exists"
+
+[account.usernameInvalid]
+other = "username can only contain letters, numbers"
+
+[account.phoneNumberDuplicate]
+other = "phone number already exists"
+
+[account.loginFailed]
+other = "login failed, please check your username and password"
\ No newline at end of file
diff --git a/locales/zh_hans.toml b/locales/zh_hans.toml
new file mode 100644
index 0000000..bb5a604
--- /dev/null
+++ b/locales/zh_hans.toml
@@ -0,0 +1,23 @@
+[apiMixin.internalServerError]
+other = "服务器异常,请联系管理员"
+
+[apiMixin.notFound]
+other = "资源不存在"
+
+[apiMixin.noPermission]
+other = "无权限"
+
+[apiMixin.unAuthorized]
+other = "未登录"
+
+[account.usernameDuplicate]
+other = "用户名或昵称已被使用"
+
+[account.usernameInvalid]
+other = "用户名只能包含字母或数字"
+
+[account.phoneNumberDuplicate]
+other = "手机号已被使用"
+
+[account.loginFailed]
+other = "用户名或密码错误"
\ No newline at end of file
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..f32a9b8
--- /dev/null
+++ b/main.go
@@ -0,0 +1,14 @@
+package main
+
+import "github.com/ovinc-cn/apicenter/v2/internal/cmd"
+
+// @title Api Center
+// @version 2.0.0
+// @contact.name OVINC CN
+// @contact.url https://github.com/OVINC-CN
+// @contact.email contact@ovinc.cn
+// @license.name MIT License
+// @license.url https://github.com/OVINC-CN/APICenter/blob/main/LICENSE
+func main() {
+ cmd.Execute()
+}
diff --git a/manage.py b/manage.py
deleted file mode 100755
index 0b92991..0000000
--- a/manage.py
+++ /dev/null
@@ -1,17 +0,0 @@
-# pylint: disable=R0801,C0415
-
-import os
-import sys
-
-
-def main():
- os.environ.setdefault("DJANGO_SETTINGS_MODULE", "entry.settings")
- try:
- from django.core.management import execute_from_command_line
- except ImportError as exc:
- raise ImportError("Couldn't import Django") from exc
- execute_from_command_line(sys.argv)
-
-
-if __name__ == "__main__":
- main()
diff --git a/pkg/apiMixin/constants.go b/pkg/apiMixin/constants.go
new file mode 100644
index 0000000..b675616
--- /dev/null
+++ b/pkg/apiMixin/constants.go
@@ -0,0 +1,10 @@
+package apiMixin
+
+const (
+ TraceContextKey = "otel_trace_context"
+ UsernameContextKey = "username"
+ UserIDContextKey = "user_id"
+ UserContextKey = "user"
+ I18nLangCookieKey = "language"
+ I18nLangCookieTTL = 3600 * 24 * 365
+)
diff --git a/pkg/apiMixin/i18n.go b/pkg/apiMixin/i18n.go
new file mode 100644
index 0000000..71922a2
--- /dev/null
+++ b/pkg/apiMixin/i18n.go
@@ -0,0 +1,17 @@
+package apiMixin
+
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/ovinc-cn/apicenter/v2/pkg/i18n"
+)
+
+func Translate(c *gin.Context, messageID string, data map[string]string) string {
+ // load language
+ lang, _ := c.Cookie(I18nLangCookieKey)
+ if lang == "" {
+ lang = i18n.DefaultLang.String()
+ }
+
+ // translate
+ return i18n.Translate(lang, messageID, data)
+}
diff --git a/pkg/apiMixin/middlewares.go b/pkg/apiMixin/middlewares.go
new file mode 100644
index 0000000..a952722
--- /dev/null
+++ b/pkg/apiMixin/middlewares.go
@@ -0,0 +1,85 @@
+package apiMixin
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/ovinc-cn/apicenter/v2/pkg/logger"
+ "github.com/ovinc-cn/apicenter/v2/pkg/trace"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/codes"
+ semconv "go.opentelemetry.io/otel/semconv/v1.37.0"
+)
+
+func ObserveMiddleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // load path
+ path := c.FullPath()
+ if path == "" {
+ path = "Unknown"
+ }
+
+ // init trace
+ ctx, span := trace.StartSpan(context.Background(), fmt.Sprintf("Router#%s#%s", c.Request.Method, path), trace.SpanKindServer)
+ defer span.End()
+ span.SetAttributes(
+ attribute.String(trace.AttributeRequestURI, c.Request.RequestURI),
+ attribute.String(trace.AttributeRequestRemoteAddr, c.Request.RemoteAddr),
+ attribute.Int64(trace.AttributeRequestContentLength, c.Request.ContentLength),
+ )
+
+ // add trace to context
+ SetTraceCtx(c, ctx)
+
+ // record time
+ startTime := time.Now()
+
+ // do next
+ c.Next()
+
+ // record duration
+ duration := time.Since(startTime)
+
+ // response status
+ status := c.Writer.Status()
+ if status >= http.StatusBadRequest {
+ span.SetStatus(codes.Error, fmt.Sprintf("status %d", status))
+ }
+
+ // add attributes
+ span.SetAttributes(
+ semconv.HTTPResponseStatusCode(status),
+ attribute.Int(trace.AttributeStatusCode, status),
+ )
+
+ // log
+ logger.Logger(ctx).Info(
+ fmt.Sprintf("[ObserveMiddleware] %d | %v | %s | %s", status, duration, c.Request.Method, c.Request.RequestURI),
+ )
+ }
+}
+
+func RecoveryMiddleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ defer func() {
+ if r := recover(); r != nil {
+ // init
+ ctx, span := trace.StartSpan(GetTraceCtx(c), "RecoveryMiddleware", trace.SpanKindInternal)
+ defer span.End()
+
+ // set error
+ span.SetStatus(codes.Error, fmt.Sprintf("%T", r))
+
+ // log
+ logger.Logger(ctx).Error(fmt.Sprintf("[RecoveryMiddleware] server panic\n%v", r))
+
+ // return 500
+ ResponseInternalError(c)
+ }
+ }()
+ c.Next()
+ }
+}
diff --git a/pkg/apiMixin/models.go b/pkg/apiMixin/models.go
new file mode 100644
index 0000000..d7ba3ed
--- /dev/null
+++ b/pkg/apiMixin/models.go
@@ -0,0 +1,16 @@
+package apiMixin
+
+type APIError struct {
+ Code int
+ Msg string
+}
+
+func (e *APIError) Error() string {
+ return e.Msg
+}
+
+type ResponseModel struct {
+ TraceID string `json:"trace_id"`
+ Message string `json:"message"`
+ Data interface{} `json:"data"`
+}
diff --git a/pkg/apiMixin/response.go b/pkg/apiMixin/response.go
new file mode 100644
index 0000000..8ece20f
--- /dev/null
+++ b/pkg/apiMixin/response.go
@@ -0,0 +1,51 @@
+package apiMixin
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/ovinc-cn/apicenter/v2/pkg/trace"
+)
+
+func Response(c *gin.Context, status int, msg string, data interface{}) {
+ // load context
+ ctx := GetTraceCtx(c)
+ span := trace.SpanFromContext(ctx)
+ traceID := span.SpanContext().TraceID().String()
+
+ // response
+ responseData := gin.H{"trace_id": traceID, "message": msg, "data": data}
+ if status >= http.StatusBadRequest {
+ c.AbortWithStatusJSON(status, responseData)
+ return
+ }
+ c.JSON(status, responseData)
+}
+
+func ResponseSuccess(c *gin.Context, data interface{}) {
+ Response(c, http.StatusOK, "", data)
+}
+
+func ResponseError(c *gin.Context, error *APIError) {
+ Response(c, error.Code, error.Msg, nil)
+}
+
+func ResponseBadRequest(c *gin.Context, msg string) {
+ Response(c, http.StatusBadRequest, msg, nil)
+}
+
+func ResponseUnauthorized(c *gin.Context) {
+ Response(c, http.StatusUnauthorized, Translate(c, "apiMixin.unAuthorized", nil), nil)
+}
+
+func ResponseForbidden(c *gin.Context) {
+ Response(c, http.StatusForbidden, Translate(c, "apiMixin.noPermission", nil), nil)
+}
+
+func ResponseNotFound(c *gin.Context) {
+ Response(c, http.StatusNotFound, Translate(c, "apiMixin.notFound", nil), nil)
+}
+
+func ResponseInternalError(c *gin.Context) {
+ Response(c, http.StatusInternalServerError, Translate(c, "apiMixin.internalServerError", nil), nil)
+}
diff --git a/pkg/apiMixin/utils.go b/pkg/apiMixin/utils.go
new file mode 100644
index 0000000..606e29c
--- /dev/null
+++ b/pkg/apiMixin/utils.go
@@ -0,0 +1,46 @@
+package apiMixin
+
+import (
+ "context"
+ "encoding/json"
+
+ "github.com/gin-gonic/gin"
+)
+
+func SetTraceCtx(c *gin.Context, ctx context.Context) {
+ c.Set(TraceContextKey, ctx)
+}
+
+func GetTraceCtx(c *gin.Context) context.Context {
+ return c.MustGet(TraceContextKey).(context.Context)
+}
+
+func SetUsername(c *gin.Context, username string) {
+ c.Set(UsernameContextKey, username)
+}
+
+func GetUsername(c *gin.Context) string {
+ return c.MustGet(UsernameContextKey).(string)
+}
+
+func SetUserID(c *gin.Context, userID uint64) {
+ c.Set(UserIDContextKey, userID)
+}
+
+func GetUserID(c *gin.Context) uint64 {
+ return c.MustGet(UserIDContextKey).(uint64)
+}
+
+func SetUser(c *gin.Context, user interface{}) error {
+ userBytes, err := json.Marshal(user)
+ if err != nil {
+ return err
+ }
+ c.Set(UserContextKey, userBytes)
+ return nil
+}
+
+func GetUser[T interface{}](c *gin.Context, user *T) error {
+ userBytes := c.MustGet(UserContextKey).([]byte)
+ return json.Unmarshal(userBytes, user)
+}
diff --git a/pkg/asyncTask/middlewares.go b/pkg/asyncTask/middlewares.go
new file mode 100644
index 0000000..b4fe031
--- /dev/null
+++ b/pkg/asyncTask/middlewares.go
@@ -0,0 +1,83 @@
+package asyncTask
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "runtime"
+
+ "github.com/hibiken/asynq"
+ "github.com/ovinc-cn/apicenter/v2/pkg/logger"
+ "github.com/ovinc-cn/apicenter/v2/pkg/trace"
+ "github.com/ovinc-cn/apicenter/v2/pkg/utils"
+ "go.opentelemetry.io/otel"
+)
+
+type ScheduleTaskFailed struct {
+ TaskName string
+}
+
+func (e ScheduleTaskFailed) Error() string {
+ return fmt.Sprintf("%sFailed", e.TaskName)
+}
+
+func ObserveMiddleware(next asynq.Handler) asynq.Handler {
+ propagator := otel.GetTextMapPropagator()
+ return asynq.HandlerFunc(func(ctx context.Context, task *asynq.Task) error {
+ // parse payload to map
+ var payload TaskPayload
+ if err := json.Unmarshal(task.Payload(), &payload); err != nil {
+ logger.Logger(ctx).Error(
+ fmt.Sprintf("[%s] failed\npayload: %s\nerr: %s", task.Type(), utils.ForceStringMaxLength(task.Payload()), err),
+ )
+ return err
+ }
+
+ // load trace if exists
+ if len(payload.Carrier) > 0 {
+ ctx = propagator.Extract(ctx, payload.Carrier)
+ }
+
+ // span
+ ctx, span := trace.StartSpan(ctx, fmt.Sprintf("ObserveTask#%s", task.Type()), trace.SpanKindInternal)
+ defer span.End()
+
+ // pre exec
+ logger.Logger(ctx).Info(fmt.Sprintf("[%s] start", task.Type()))
+
+ // execute
+ err := next.ProcessTask(ctx, task)
+
+ // post exec
+ if err != nil {
+ logger.Logger(ctx).Error(
+ fmt.Sprintf("[%s] failed\npayload: %s\nerr: %s", task.Type(), utils.ForceStringMaxLength(task.Payload()), err),
+ )
+ } else {
+ logger.Logger(ctx).Info(fmt.Sprintf("[%s] finished", task.Type()))
+ }
+ return err
+ })
+}
+
+func RecoverMiddleware(next asynq.Handler) asynq.Handler {
+ return asynq.HandlerFunc(func(ctx context.Context, task *asynq.Task) (err error) {
+ defer func() {
+ if r := recover(); r != nil {
+ ctx, span := trace.StartSpan(ctx, fmt.Sprintf("Task#%s#Panic", task.Type()), trace.SpanKindInternal)
+ defer span.End()
+
+ var stack []byte
+ var length int
+ stack = make([]byte, 4096)
+ length = runtime.Stack(stack, true)
+ stack = stack[:length]
+
+ logger.Logger(ctx).Error(
+ fmt.Sprintf("[%s] panic\nerr: %s\n%s", task.Type(), utils.ForceStringMaxLength(r), stack),
+ )
+ }
+ }()
+ return next.ProcessTask(ctx, task)
+ })
+}
diff --git a/pkg/asyncTask/models.go b/pkg/asyncTask/models.go
new file mode 100644
index 0000000..0b66633
--- /dev/null
+++ b/pkg/asyncTask/models.go
@@ -0,0 +1,8 @@
+package asyncTask
+
+import "go.opentelemetry.io/otel/propagation"
+
+type TaskPayload struct {
+ Carrier propagation.MapCarrier
+ Data interface{}
+}
diff --git a/pkg/cfg/config.go b/pkg/cfg/config.go
new file mode 100644
index 0000000..20f933e
--- /dev/null
+++ b/pkg/cfg/config.go
@@ -0,0 +1,88 @@
+package cfg
+
+import (
+ "log"
+ "time"
+)
+
+// Base
+var (
+ AppName = func() string {
+ return MustGetConfigVal("APP_NAME")
+ }
+ AppDebug = func() bool {
+ return GetConfigBool("APP_DEBUG", false)
+ }
+ AppTimezone = func() *time.Location {
+ tz := GetConfigVal("APP_TIMEZONE", "Asia/Shanghai")
+ loc, err := time.LoadLocation(tz)
+ if err != nil {
+ log.Fatalf("[Config] load timezone %s failed; %s", tz, err)
+ }
+ return loc
+ }
+)
+
+// Log
+var (
+ AppLogLevel = func() string {
+ return GetConfigVal("APP_LOG_LEVEL", "INFO")
+ }
+ AppLogEncoding = func() string {
+ return GetConfigVal("APP_LOG_ENCODING", "json")
+ }
+ AppLogOutput = func() string {
+ return GetConfigVal("APP_LOG_OUTPUT", "stdout")
+ }
+)
+
+// mysql
+var (
+ MySQLUsername = func() string {
+ return GetConfigVal("APP_MYSQL_USERNAME", "root")
+ }
+ MySQLPassword = func() string {
+ return GetConfigVal("APP_MYSQL_PASSWORD", "")
+ }
+ MySQLAddr = func() string {
+ return GetConfigVal("APP_MYSQL_ADDR", "127.0.0.1:3306")
+ }
+ MySQLDatabase = func() string {
+ return GetConfigVal("APP_MYSQL_DATABASE", "api_center")
+ }
+ MySQLParams = func() string {
+ return GetConfigVal("APP_MYSQL_CONNECT_ARGS", "charset=utf8mb4&parseTime=True&loc=UTC")
+ }
+ MySQLMaxOpenConns = func() int {
+ return GetConfigInt("APP_MYSQL_MAX_OPEN_CONNS", 100)
+ }
+ MySQLMaxIdleConns = func() int {
+ return GetConfigInt("APP_MYSQL_MAX_IDLE_CONNS", 10)
+ }
+ MySQLConnMaxLifetime = func() time.Duration {
+ return time.Duration(GetConfigInt("APP_MYSQL_CONN_MAX_LIFETIME", 3600)) * time.Second
+ }
+)
+
+// redis
+var (
+ RedisAddr = func() string {
+ return GetConfigVal("APP_REDIS_ADDR", "127.0.0.1:6379")
+ }
+ RedisPassword = func() string {
+ return GetConfigVal("APP_REDIS_PASSWORD", "")
+ }
+ RedisDB = func() int {
+ return GetConfigInt("APP_REDIS_DB", 0)
+ }
+)
+
+// Trace
+var (
+ EnableTrace = func() bool {
+ return GetConfigBool("APP_ENABLE_TRACE", true)
+ }
+ TraceEndpoint = func() string {
+ return GetConfigVal("APP_TRACE_ENDPOINT", "127.0.0.1:4317")
+ }
+)
diff --git a/pkg/cfg/utils.go b/pkg/cfg/utils.go
new file mode 100644
index 0000000..cbf65fc
--- /dev/null
+++ b/pkg/cfg/utils.go
@@ -0,0 +1,111 @@
+package cfg
+
+import (
+ "encoding/json"
+ "log"
+ "os"
+ "strconv"
+ "strings"
+
+ "github.com/joho/godotenv"
+)
+
+func init() {
+ // load from env file
+ if err := godotenv.Load(); err != nil {
+ log.Printf("[Config] error reading config: %s", err)
+ }
+}
+
+func GetConfigVal(key string, defaultVal string) string {
+ // load from env
+ if val := os.Getenv(key); val != "" {
+ return val
+ }
+ // default val
+ return defaultVal
+}
+
+func GetConfigBool(key string, defaultVal bool) bool {
+ val := GetConfigVal(key, "")
+ if val == "" {
+ return defaultVal
+ }
+ return strings.ToLower(val) == "true"
+}
+
+func GetConfigInt(key string, defaultVal int) int {
+ val := GetConfigVal(key, "")
+ if val == "" {
+ return defaultVal
+ }
+ intVal, err := strconv.Atoi(val)
+ if err != nil {
+ log.Fatalf("[Config] parse %s of %s to int failed; %s", key, val, err)
+ }
+ return intVal
+}
+
+func GetConfigInt64(key string, defaultVal int64) int64 {
+ val := GetConfigVal(key, "")
+ if val == "" {
+ return defaultVal
+ }
+ intVal, err := strconv.ParseInt(val, 10, 64)
+ if err != nil {
+ log.Fatalf("[Config] parse %s of %s to int64 failed; %s", key, val, err)
+ }
+ return intVal
+}
+
+func GetConfigUint(key string, defaultVal uint) uint {
+ val := GetConfigVal(key, "")
+ if val == "" {
+ return defaultVal
+ }
+ uintVal, err := strconv.ParseUint(val, 10, strconv.IntSize)
+ if err != nil {
+ log.Fatalf("[Config] parse %s of %s to uint failed; %s", key, val, err)
+ }
+ return uint(uintVal)
+}
+
+func GetConfigUint64(key string, defaultVal uint64) uint64 {
+ val := GetConfigVal(key, "")
+ if val == "" {
+ return defaultVal
+ }
+ uintVal, err := strconv.ParseUint(val, 10, 64)
+ if err != nil {
+ log.Fatalf("[Config] parse %s of %s to uint64 failed; %s", key, val, err)
+ }
+ return uintVal
+}
+
+func GetConfigFloat64(key string, defaultVal float64) float64 {
+ val := GetConfigVal(key, "")
+ if val == "" {
+ return defaultVal
+ }
+ floatVal, err := strconv.ParseFloat(val, 64)
+ if err != nil {
+ log.Fatalf("[Config] parse %s of %s to float64 failed; %s", key, val, err)
+ }
+ return floatVal
+}
+
+func GetConfigStruct[T interface{}](key string, defaultVal string, out T) T {
+ val := GetConfigVal(key, defaultVal)
+ if err := json.Unmarshal([]byte(val), &out); err != nil {
+ log.Fatalf("[Config] parse %s of `%s` to struct failed; %s", key, val, err)
+ }
+ return out
+}
+
+func MustGetConfigVal(key string) string {
+ if val := GetConfigVal(key, ""); val != "" {
+ return val
+ }
+ log.Fatalf("[Config] %s is empty", key)
+ return ""
+}
diff --git a/pkg/i18n/i18n.go b/pkg/i18n/i18n.go
new file mode 100644
index 0000000..30cc5e2
--- /dev/null
+++ b/pkg/i18n/i18n.go
@@ -0,0 +1,71 @@
+package i18n
+
+import (
+ "io/fs"
+ "log"
+ "path/filepath"
+ "strings"
+ "text/template"
+
+ "github.com/BurntSushi/toml"
+ "github.com/nicksnyder/go-i18n/v2/i18n"
+ "golang.org/x/text/language"
+)
+
+var bundle *i18n.Bundle
+var DefaultLang = language.SimplifiedChinese
+var localeFilePath = "locales"
+
+func init() {
+ // init
+ bundle = i18n.NewBundle(DefaultLang)
+ bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
+ if err := filepath.WalkDir(
+ localeFilePath,
+ func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if !d.IsDir() {
+ if _, err := bundle.LoadMessageFile(path); err != nil {
+ log.Printf("[I18n] load locales %s fail: %s\n", path, err.Error())
+ }
+ log.Printf("[I18n] load locales %s success\n", path)
+ }
+ return nil
+ },
+ ); err != nil {
+ log.Printf("[I18n] read locales dir %s fail: %s\n", localeFilePath, err.Error())
+ }
+ log.Printf("[I18n] init success\n")
+}
+
+func getLocalizer(lang string) *i18n.Localizer {
+ if lang == "" {
+ return getLocalizer(DefaultLang.String())
+ }
+ return i18n.NewLocalizer(bundle, lang)
+}
+
+func Translate(lang string, messageID string, data map[string]string) string {
+ msg, err := getLocalizer(lang).Localize(
+ &i18n.LocalizeConfig{
+ MessageID: messageID,
+ TemplateData: data,
+ DefaultMessage: &i18n.Message{ID: messageID, Other: messageID},
+ },
+ )
+ if err != nil {
+ tmpl := template.New("msg").Option("missingkey=zero")
+ t, err := tmpl.Parse(messageID)
+ if err != nil {
+ return messageID
+ }
+ var buf strings.Builder
+ if err := t.Execute(&buf, data); err != nil {
+ return messageID
+ }
+ return buf.String()
+ }
+ return msg
+}
diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go
new file mode 100644
index 0000000..4c4c929
--- /dev/null
+++ b/pkg/logger/logger.go
@@ -0,0 +1,48 @@
+package logger
+
+import (
+ "context"
+ "log"
+
+ "github.com/ovinc-cn/apicenter/v2/pkg/cfg"
+ "github.com/ovinc-cn/apicenter/v2/pkg/trace"
+ "go.uber.org/zap"
+ "go.uber.org/zap/zapcore"
+)
+
+var logger *zap.Logger
+
+func init() {
+ var err error
+ config := zap.Config{
+ Level: LogLevel(),
+ Development: cfg.AppDebug(),
+ Encoding: cfg.AppLogEncoding(),
+ EncoderConfig: zapcore.EncoderConfig{
+ MessageKey: "message",
+ LevelKey: "level",
+ TimeKey: "time",
+ NameKey: "logger",
+ CallerKey: "caller",
+ StacktraceKey: "stacktrace",
+ EncodeLevel: zapcore.LowercaseLevelEncoder,
+ EncodeTime: zapcore.RFC3339NanoTimeEncoder,
+ EncodeDuration: zapcore.MillisDurationEncoder,
+ EncodeCaller: zapcore.FullCallerEncoder,
+ EncodeName: zapcore.FullNameEncoder,
+ },
+ OutputPaths: []string{cfg.AppLogOutput()},
+ }
+ if logger, err = config.Build(); err != nil {
+ log.Fatalf("[Logger] init failed; %s", err)
+ }
+}
+
+func Logger(ctx context.Context) *zap.Logger {
+ span := trace.SpanFromContext(ctx)
+ spanCtx := span.SpanContext()
+ return logger.With(
+ zap.String("trace_id", spanCtx.TraceID().String()),
+ zap.String("span_id", spanCtx.SpanID().String()),
+ )
+}
diff --git a/pkg/logger/utils.go b/pkg/logger/utils.go
new file mode 100644
index 0000000..a870fc3
--- /dev/null
+++ b/pkg/logger/utils.go
@@ -0,0 +1,32 @@
+package logger
+
+import (
+ "log"
+ "strings"
+
+ "github.com/ovinc-cn/apicenter/v2/pkg/cfg"
+ "go.uber.org/zap"
+)
+
+func LogLevel() zap.AtomicLevel {
+ level := strings.ToUpper(cfg.AppLogLevel())
+ switch level {
+ case "DEBUG":
+ return zap.NewAtomicLevelAt(zap.DebugLevel)
+ case "INFO":
+ return zap.NewAtomicLevelAt(zap.InfoLevel)
+ case "WARN", "WARNING":
+ return zap.NewAtomicLevelAt(zap.WarnLevel)
+ case "ERROR":
+ return zap.NewAtomicLevelAt(zap.ErrorLevel)
+ case "DPANIC":
+ return zap.NewAtomicLevelAt(zap.DPanicLevel)
+ case "PANIC":
+ return zap.NewAtomicLevelAt(zap.PanicLevel)
+ case "FATAL":
+ return zap.NewAtomicLevelAt(zap.FatalLevel)
+ default:
+ log.Printf("[Logger] unknown log level %s, use INFO level", level)
+ return zap.NewAtomicLevelAt(zap.InfoLevel)
+ }
+}
diff --git a/pkg/mysql/commands.go b/pkg/mysql/commands.go
new file mode 100644
index 0000000..57110eb
--- /dev/null
+++ b/pkg/mysql/commands.go
@@ -0,0 +1,112 @@
+package mysql
+
+import (
+ "context"
+
+ "github.com/ovinc-cn/apicenter/v2/pkg/cfg"
+ "github.com/ovinc-cn/apicenter/v2/pkg/trace"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/codes"
+ "gorm.io/gorm"
+)
+
+func buildAttributes(attributes ...attribute.KeyValue) []attribute.KeyValue {
+ return append(
+ []attribute.KeyValue{
+ attribute.String(trace.AttributeDBSystem, "mysql"),
+ attribute.String(trace.AttributeDBIP, cfg.MySQLAddr()),
+ attribute.String(trace.AttributeDBInstance, cfg.MySQLDatabase()),
+ },
+ attributes...,
+ )
+}
+
+func Count(ctx context.Context, db *gorm.DB, count *int64) error {
+ // trace
+ ctx, span := trace.StartSpan(ctx, "MySQL#Count", trace.SpanKindClient)
+ defer span.End()
+
+ // exec
+ result := db.WithContext(ctx).Count(count)
+ err := result.Error
+ if err != nil {
+ span.SetStatus(codes.Error, err.Error())
+ }
+
+ // add attributes
+ span.SetAttributes(buildAttributes()...)
+
+ return err
+}
+
+func Create(ctx context.Context, db *gorm.DB, value interface{}) error {
+ // trace
+ ctx, span := trace.StartSpan(ctx, "MySQL#Create", trace.SpanKindClient)
+ defer span.End()
+
+ // exec
+ result := db.WithContext(ctx).Create(value)
+ err := result.Error
+ if err != nil {
+ span.SetStatus(codes.Error, err.Error())
+ }
+
+ // add attributes
+ span.SetAttributes(buildAttributes()...)
+
+ return err
+}
+
+func Select(ctx context.Context, db *gorm.DB, dest interface{}, conditions ...interface{}) error {
+ // trace
+ ctx, span := trace.StartSpan(ctx, "MySQL#Select", trace.SpanKindClient)
+ defer span.End()
+
+ // exec
+ result := db.WithContext(ctx).Find(dest, conditions...)
+ err := result.Error
+ if err != nil {
+ span.SetStatus(codes.Error, err.Error())
+ }
+
+ // add attributes
+ span.SetAttributes(buildAttributes()...)
+
+ return err
+}
+
+func Update[T interface{}](ctx context.Context, db *gorm.DB, model *T, column string, value interface{}) error {
+ // trace
+ ctx, span := trace.StartSpan(ctx, "MySQL#Update", trace.SpanKindClient)
+ defer span.End()
+
+ // exec
+ result := db.WithContext(ctx).Model(model).Update(column, value)
+ err := result.Error
+ if err != nil {
+ span.SetStatus(codes.Error, err.Error())
+ }
+
+ // add attributes
+ span.SetAttributes(buildAttributes()...)
+
+ return err
+}
+
+func Save(ctx context.Context, db *gorm.DB, value interface{}) error {
+ // trace
+ ctx, span := trace.StartSpan(ctx, "MySQL#Save", trace.SpanKindClient)
+ defer span.End()
+
+ // exec
+ result := db.WithContext(ctx).Save(value)
+ err := result.Error
+ if err != nil {
+ span.SetStatus(codes.Error, err.Error())
+ }
+
+ // add attributes
+ span.SetAttributes(buildAttributes()...)
+
+ return err
+}
diff --git a/pkg/mysql/mysql.go b/pkg/mysql/mysql.go
new file mode 100644
index 0000000..040a3fd
--- /dev/null
+++ b/pkg/mysql/mysql.go
@@ -0,0 +1,38 @@
+package mysql
+
+import (
+ "log"
+
+ "github.com/ovinc-cn/apicenter/v2/pkg/cfg"
+ "gorm.io/gorm"
+)
+
+var db *gorm.DB
+
+func init() {
+ // open conn
+ var err error
+ db, err = openConn()
+ if err != nil {
+ log.Fatalf("[MySQL] connect to mysql failed; %s", err)
+ }
+ // debug
+ if cfg.AppDebug() {
+ db = db.Debug()
+ }
+ // try to connect
+ sqlDB, err := db.DB()
+ if err != nil || sqlDB.Ping() != nil {
+ log.Fatalf("[MySQL] ping mysql failed; %s", err)
+ }
+ // set config
+ sqlDB.SetMaxOpenConns(cfg.MySQLMaxOpenConns())
+ sqlDB.SetMaxIdleConns(cfg.MySQLMaxIdleConns())
+ sqlDB.SetConnMaxLifetime(cfg.MySQLConnMaxLifetime())
+ // log
+ log.Printf("[MySQL] connect to mysql success\n")
+}
+
+func DB() *gorm.DB {
+ return db
+}
diff --git a/pkg/mysql/utils.go b/pkg/mysql/utils.go
new file mode 100644
index 0000000..80c94ea
--- /dev/null
+++ b/pkg/mysql/utils.go
@@ -0,0 +1,44 @@
+package mysql
+
+import (
+ "fmt"
+
+ "github.com/ovinc-cn/apicenter/v2/pkg/cfg"
+ "github.com/ovinc-cn/apicenter/v2/pkg/trace"
+ "go.opentelemetry.io/otel/attribute"
+ "gorm.io/driver/mysql"
+ "gorm.io/gorm"
+ "gorm.io/plugin/opentelemetry/tracing"
+)
+
+func openConn() (*gorm.DB, error) {
+ // init dsn
+ dsn := fmt.Sprintf(
+ "%s:%s@tcp(%s)/%s?%s",
+ cfg.MySQLUsername(),
+ cfg.MySQLPassword(),
+ cfg.MySQLAddr(),
+ cfg.MySQLDatabase(),
+ cfg.MySQLParams(),
+ )
+ // init db
+ gormDB, err := gorm.Open(
+ mysql.Open(dsn),
+ &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true},
+ )
+ if err != nil {
+ return nil, err
+ }
+ // add trace
+ if err := gormDB.Use(tracing.NewPlugin(
+ tracing.WithoutMetrics(),
+ tracing.WithAttributes(
+ attribute.String(trace.AttributeDBSystem, "mysql"),
+ attribute.String(trace.AttributeDBIP, cfg.MySQLAddr()),
+ attribute.String(trace.AttributeDBInstance, cfg.MySQLDatabase()),
+ ),
+ )); err != nil {
+ return nil, err
+ }
+ return gormDB, nil
+}
diff --git a/pkg/password/check.go b/pkg/password/check.go
new file mode 100644
index 0000000..2047b95
--- /dev/null
+++ b/pkg/password/check.go
@@ -0,0 +1,40 @@
+package password
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "hash"
+ "strconv"
+ "strings"
+
+ "golang.org/x/crypto/pbkdf2"
+)
+
+func CheckPassword(password, encoded string) (bool, error) {
+ if strings.Index(encoded, unusableHash) == 0 {
+ _, _ = MakePassword(password)
+ return false, nil
+ }
+ return checkPbkdf2(password, encoded, sha256.Size, sha256.New)
+}
+
+func checkPbkdf2(password, encoded string, keyLen int, h func() hash.Hash) (bool, error) {
+ parts := strings.SplitN(encoded, "$", 4)
+ if len(parts) != 4 {
+ return false, errors.New("hash must consist of 4 segments")
+ }
+ iter, err := strconv.Atoi(parts[1])
+ if err != nil {
+ return false, fmt.Errorf("wrong number of iterations: %v", err)
+ }
+ salt := []byte(parts[2])
+ k, err := base64.StdEncoding.DecodeString(parts[3])
+ if err != nil {
+ return false, fmt.Errorf("wrong hash encoding: %v", err)
+ }
+ dk := pbkdf2.Key([]byte(password), salt, iter, keyLen, h)
+ return bytes.Equal(k, dk), nil
+}
diff --git a/pkg/password/make.go b/pkg/password/make.go
new file mode 100644
index 0000000..fde0163
--- /dev/null
+++ b/pkg/password/make.go
@@ -0,0 +1,68 @@
+package password
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "fmt"
+ "hash"
+ "math/big"
+
+ "golang.org/x/crypto/pbkdf2"
+)
+
+const (
+ iter = 100000
+ saltSize = 22
+ hashAlg = "pbkdf2_sha256"
+ allowedChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+ unusableHash = "!"
+)
+
+func Iter() int {
+ return iter
+}
+
+func SaltSize() int {
+ return saltSize
+}
+
+func HashAlg() string {
+ return hashAlg
+}
+
+func AllowedChars() string {
+ return allowedChars
+}
+
+func UnusableHash() string {
+ return unusableHash
+}
+
+func MakePassword(password string) (string, error) {
+ if password == "" {
+ return unusableHash, nil
+ }
+ return makePbkdf2(password, sha256.Size, sha256.New)
+}
+
+func makePbkdf2(password string, keyLen int, h func() hash.Hash) (string, error) {
+ salt := getRandomSalt(saltSize)
+ dk := pbkdf2.Key([]byte(password), salt, iter, keyLen, h)
+ b64Hash := base64.StdEncoding.EncodeToString(dk)
+ return fmt.Sprintf("%s$%d$%s$%s", hashAlg, iter, salt, b64Hash), nil
+}
+
+func getRandomSalt(size int) []byte {
+ salt := make([]byte, size)
+ l := int64(len(allowedChars))
+ for i := range salt {
+ nBig, err := rand.Int(rand.Reader, big.NewInt(l))
+ if err != nil {
+ // If we can't generate randomness, fail securely.
+ panic(fmt.Sprintf("failed to generate cryptographically secure salt: %v", err))
+ }
+ salt[i] = allowedChars[nBig.Int64()]
+ }
+ return salt
+}
diff --git a/pkg/redis/commands.go b/pkg/redis/commands.go
new file mode 100644
index 0000000..6d76390
--- /dev/null
+++ b/pkg/redis/commands.go
@@ -0,0 +1,141 @@
+package redis
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/ovinc-cn/apicenter/v2/pkg/cfg"
+ "github.com/ovinc-cn/apicenter/v2/pkg/trace"
+ "github.com/ovinc-cn/apicenter/v2/pkg/utils"
+ "github.com/redis/go-redis/v9"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/codes"
+)
+
+func buildAttributes(attrs ...attribute.KeyValue) []attribute.KeyValue {
+ return append(
+ []attribute.KeyValue{
+ attribute.String(trace.AttributeDBSystem, "redis"),
+ attribute.String(trace.AttributeDBIP, cfg.RedisAddr()),
+ attribute.Int(trace.AttributeDBInstance, cfg.RedisDB()),
+ },
+ attrs...,
+ )
+}
+
+func Ping(ctx context.Context, conn redis.Cmdable) error {
+ // trace
+ ctx, span := trace.StartSpan(ctx, "Redis#Ping", trace.SpanKindClient)
+ defer span.End()
+
+ // exec
+ cmd := conn.Ping(ctx)
+ if err := cmd.Err(); err != nil {
+ span.SetStatus(codes.Error, err.Error())
+ return err
+ }
+ return nil
+}
+
+func Set(ctx context.Context, conn redis.Cmdable, k string, v interface{}, ex time.Duration) (string, error) {
+ // trace
+ ctx, span := trace.StartSpan(ctx, "Redis#Set", trace.SpanKindClient)
+ defer span.End()
+
+ // add data
+ span.SetAttributes(
+ buildAttributes(
+ attribute.String("key", k),
+ attribute.String("val", utils.ForceStringMaxLength(v)),
+ attribute.String("ex", ex.String()),
+ )...,
+ )
+
+ // exec
+ cmd := conn.Set(ctx, k, v, ex)
+ if err := cmd.Err(); err != nil {
+ span.SetStatus(codes.Error, err.Error())
+ return "", err
+ }
+ return cmd.Val(), nil
+}
+
+func SetNX(ctx context.Context, conn redis.Cmdable, k string, v interface{}, ex time.Duration) (bool, error) {
+ // trace
+ ctx, span := trace.StartSpan(ctx, "Redis#SetNX", trace.SpanKindClient)
+ defer span.End()
+
+ // add data
+ span.SetAttributes(
+ buildAttributes(
+ attribute.String("key", k),
+ attribute.String("val", utils.ForceStringMaxLength(v)),
+ attribute.String("ex", ex.String()),
+ )...,
+ )
+
+ // exec
+ cmd := conn.SetNX(ctx, k, v, ex)
+ if err := cmd.Err(); err != nil {
+ span.SetStatus(codes.Error, err.Error())
+ return false, err
+ }
+ return cmd.Val(), nil
+}
+
+func Get(ctx context.Context, conn redis.Cmdable, k string) (string, error) {
+ // trace
+ ctx, span := trace.StartSpan(ctx, "Redis#Get", trace.SpanKindClient)
+ defer span.End()
+
+ // add data
+ span.SetAttributes(buildAttributes(attribute.String("key", k))...)
+
+ // exec
+ cmd := conn.Get(ctx, k)
+ if err := cmd.Err(); err != nil {
+ if errors.Is(err, redis.Nil) {
+ return "", nil
+ }
+ span.SetStatus(codes.Error, err.Error())
+ return "", err
+ }
+
+ // result
+ return cmd.Val(), nil
+}
+
+func Del(ctx context.Context, conn redis.Cmdable, keys ...string) (int64, error) {
+ // trace
+ ctx, span := trace.StartSpan(ctx, "Redis#Del", trace.SpanKindClient)
+ defer span.End()
+
+ // add data
+ span.SetAttributes(buildAttributes(attribute.StringSlice("keys", keys))...)
+
+ // exec
+ cmd := conn.Del(ctx, keys...)
+ if err := cmd.Err(); err != nil {
+ span.SetStatus(codes.Error, err.Error())
+ return 0, err
+ }
+ return cmd.Val(), nil
+}
+
+func Exists(ctx context.Context, conn redis.Cmdable, keys ...string) (int64, error) {
+ // trace
+ ctx, span := trace.StartSpan(ctx, "Redis#Exists", trace.SpanKindClient)
+ defer span.End()
+
+ // add data
+ span.SetAttributes(buildAttributes(attribute.StringSlice("keys", keys))...)
+
+ // exec
+ cmd := conn.Exists(ctx, keys...)
+ if err := cmd.Err(); err != nil {
+ span.SetStatus(codes.Error, err.Error())
+ return 0, err
+ }
+ return cmd.Val(), nil
+}
diff --git a/pkg/redis/errs.go b/pkg/redis/errs.go
new file mode 100644
index 0000000..79f7286
--- /dev/null
+++ b/pkg/redis/errs.go
@@ -0,0 +1,11 @@
+package redis
+
+import "fmt"
+
+type ConcurrencyLocked struct {
+ Key string
+}
+
+func (c *ConcurrencyLocked) Error() string {
+ return fmt.Sprintf("concurrency locked for %s", c.Key)
+}
diff --git a/pkg/redis/lock.go b/pkg/redis/lock.go
new file mode 100644
index 0000000..67eb9b5
--- /dev/null
+++ b/pkg/redis/lock.go
@@ -0,0 +1,45 @@
+package redis
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/ovinc-cn/apicenter/v2/pkg/logger"
+ "github.com/redis/go-redis/v9"
+)
+
+type RedisLock struct {
+ locked bool
+
+ ctx context.Context
+ conn redis.Cmdable
+
+ key string
+ expiration time.Duration
+}
+
+func (l *RedisLock) Lock() error {
+ result, err := SetNX(l.ctx, l.conn, l.key, time.Now().Format(time.RFC3339Nano), l.expiration)
+ if err != nil {
+ return err
+ }
+ if !result {
+ return &ConcurrencyLocked{Key: l.key}
+ }
+ l.locked = true
+ return nil
+}
+
+func (l *RedisLock) Release() {
+ if l.locked {
+ _, err := Del(l.ctx, l.conn, l.key)
+ if err != nil {
+ logger.Logger(l.ctx).Error(fmt.Sprintf("[RedisLock] release lock %s failed; %s", l.key, err.Error()))
+ }
+ }
+}
+
+func NewLock(ctx context.Context, conn redis.Cmdable, key string, expiration time.Duration) *RedisLock {
+ return &RedisLock{ctx: ctx, conn: conn, key: key, expiration: expiration}
+}
diff --git a/pkg/redis/redis.go b/pkg/redis/redis.go
new file mode 100644
index 0000000..0b70e44
--- /dev/null
+++ b/pkg/redis/redis.go
@@ -0,0 +1,32 @@
+package redis
+
+import (
+ "context"
+ "log"
+
+ "github.com/ovinc-cn/apicenter/v2/pkg/cfg"
+ "github.com/redis/go-redis/v9"
+)
+
+var rdb *redis.Client
+
+func init() {
+ // init client
+ rdb = redis.NewClient(
+ &redis.Options{
+ Addr: cfg.RedisAddr(),
+ Password: cfg.RedisPassword(),
+ DB: cfg.RedisDB(),
+ },
+ )
+ if err := Ping(context.Background(), rdb); err != nil {
+ log.Fatalf("[RedisClient] failed to ping; %s", err)
+ }
+
+ // log
+ log.Printf("[RedisClient] connect success\n")
+}
+
+func Client() *redis.Client {
+ return rdb
+}
diff --git a/pkg/trace/constants.go b/pkg/trace/constants.go
new file mode 100644
index 0000000..a0bb574
--- /dev/null
+++ b/pkg/trace/constants.go
@@ -0,0 +1,21 @@
+package trace
+
+import otTrace "go.opentelemetry.io/otel/trace"
+
+const (
+ AttributeRequestURI = "request.uri"
+ AttributeRequestRemoteAddr = "request.remote_addr"
+ AttributeRequestContentLength = "request.content_length"
+ AttributeStatusCode = "status.code"
+ AttributeDBInstance = "db.instance"
+ AttributeDBIP = "db.ip"
+ AttributeDBSystem = "db.system"
+)
+
+const (
+ SpanKindInternal = otTrace.SpanKindInternal
+ SpanKindServer = otTrace.SpanKindServer
+ SpanKindClient = otTrace.SpanKindClient
+ SpanKindProducer = otTrace.SpanKindProducer
+ SpanKindConsumer = otTrace.SpanKindConsumer
+)
diff --git a/pkg/trace/span.go b/pkg/trace/span.go
new file mode 100644
index 0000000..8f1bda9
--- /dev/null
+++ b/pkg/trace/span.go
@@ -0,0 +1,21 @@
+package trace
+
+import (
+ "context"
+
+ "go.opentelemetry.io/otel/trace"
+)
+
+func StartSpan(
+ ctx context.Context,
+ spanName string,
+ spanKind trace.SpanKind,
+ options ...trace.SpanStartOption,
+) (context.Context, trace.Span) {
+ options = append(options, trace.WithSpanKind(spanKind))
+ return tracer.Start(ctx, spanName, options...)
+}
+
+func SpanFromContext(ctx context.Context) trace.Span {
+ return trace.SpanFromContext(ctx)
+}
diff --git a/pkg/trace/trace.go b/pkg/trace/trace.go
new file mode 100644
index 0000000..d194992
--- /dev/null
+++ b/pkg/trace/trace.go
@@ -0,0 +1,61 @@
+package trace
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/ovinc-cn/apicenter/v2/pkg/cfg"
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
+ "go.opentelemetry.io/otel/propagation"
+ "go.opentelemetry.io/otel/sdk/resource"
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
+ semconv "go.opentelemetry.io/otel/semconv/v1.37.0"
+ "go.opentelemetry.io/otel/trace"
+)
+
+var tracer trace.Tracer
+var traceProvider *sdktrace.TracerProvider
+
+func init() {
+ // init ctx
+ ctx := context.Background()
+
+ // load process name
+ if len(os.Args) < 2 {
+ log.Fatalf("[Trace] process name not found")
+ }
+ processName := os.Args[1]
+
+ // resource
+ r, err := resource.New(ctx, resource.WithAttributes(
+ semconv.ServiceName(fmt.Sprintf("%s-%s", cfg.AppName(), processName))),
+ )
+ if err != nil {
+ log.Fatalf("[Trace] create resource failed; %s", err)
+ }
+
+ // init ops
+ ops := []sdktrace.TracerProviderOption{sdktrace.WithResource(r)}
+ if cfg.EnableTrace() {
+ e, err := otlptracegrpc.New(ctx, otlptracegrpc.WithEndpoint(cfg.TraceEndpoint()), otlptracegrpc.WithInsecure())
+ if err != nil {
+ log.Fatalf("[Trace] create otlp grpc exporter failed; %s", err)
+ }
+ ops = append(ops, sdktrace.WithBatcher(e))
+ }
+
+ // init trace provider
+ traceProvider = sdktrace.NewTracerProvider(ops...)
+ otel.SetTracerProvider(traceProvider)
+ otel.SetTextMapPropagator(propagation.TraceContext{})
+
+ // init tracer
+ tracer = otel.Tracer(cfg.AppName())
+}
+
+func OnExit() {
+ _ = traceProvider.Shutdown(context.Background())
+}
diff --git a/pkg/utils/string.go b/pkg/utils/string.go
new file mode 100644
index 0000000..6438055
--- /dev/null
+++ b/pkg/utils/string.go
@@ -0,0 +1,27 @@
+package utils
+
+import (
+ "encoding/json"
+ "fmt"
+)
+
+const maxLength = 1024
+
+func MaxLength(s string) string {
+ if len(s) > maxLength {
+ s = fmt.Sprintf("%s...", s[:maxLength])
+ }
+ return s
+}
+
+func ForceString(model interface{}) string {
+ data, err := json.Marshal(model)
+ if err != nil {
+ return fmt.Sprintf("%+v", model)
+ }
+ return string(data)
+}
+
+func ForceStringMaxLength(model interface{}) string {
+ return MaxLength(ForceString(model))
+}
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index de673b8..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,27 +0,0 @@
-# ovinc
-ovinc-client==0.4.0
-
-# Celery
-celery==5.4.0
-
-# ASGI
-asgiref==3.8.1
-channels[daphne]==4.2.0
-channels_redis==4.2.1
-
-# Server
-gunicorn==23.0.0
-uvicorn-worker==0.2.0
-uvicorn==0.34.0
-
-# Datetime
-arrow==1.3.0
-
-# RSA
-pycryptodome==3.21.0
-
-# tencent cloud
-tencentcloud-sdk-python==3.0.1282
-
-# oidc
-django-oidc-provider==0.8.3
diff --git a/scripts/messages.sh b/scripts/messages.sh
deleted file mode 100755
index e9c425e..0000000
--- a/scripts/messages.sh
+++ /dev/null
@@ -1,7 +0,0 @@
-export PACKAGE_PATH=`python -c "import site; print(site.getsitepackages()[0])"`
-
-ln -s "$PACKAGE_PATH/ovinc_client" ovinc_client
-
-python manage.py makemessages -l zh_Hans --no-wrap --no-location -s
-
-rm ovinc_client
diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh
deleted file mode 100755
index 07c0956..0000000
--- a/scripts/pre-commit.sh
+++ /dev/null
@@ -1,2 +0,0 @@
-#!/bin/sh
-pre-commit run -a
diff --git a/scripts/pylint.sh b/scripts/pylint.sh
deleted file mode 100755
index db38b83..0000000
--- a/scripts/pylint.sh
+++ /dev/null
@@ -1,2 +0,0 @@
-#!/bin/sh
-pylint --disable=C0114,W0613,C0115,W1113,W0223,C0116,R0901,R0903,C0209 --max-line-length=120 $(git ls-files '*.py')
diff --git a/scripts/swagger.sh b/scripts/swagger.sh
new file mode 100755
index 0000000..49899c3
--- /dev/null
+++ b/scripts/swagger.sh
@@ -0,0 +1,27 @@
+#!/bin/sh
+
+set -e
+
+swag fmt
+
+if [ ! -f internal/version/VERSION ]; then
+ echo "internal/version/VERSION not exists"
+ exit 1
+fi
+
+VERSION=$(cat internal/version/VERSION)
+echo "Version: $VERSION"
+
+if [ ! -f main.go ]; then
+ echo "main.go not exists"
+ exit 1
+fi
+
+if [[ "$OSTYPE" == "darwin"* ]]; then
+ sed -i '' "s|// @version .*|// @version $VERSION|" main.go
+else
+ sed -i "s|// @version .*|// @version $VERSION|" main.go
+fi
+cat main.go | grep $VERSION > /dev/null || (echo "update version failed" && exit 1)
+
+swag init -o support_files/api_docs --parseDependency --parseInternal
diff --git a/scripts/tidy.sh b/scripts/tidy.sh
new file mode 100755
index 0000000..5d7764f
--- /dev/null
+++ b/scripts/tidy.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+
+set -e
+
+go mod tidy
\ No newline at end of file
diff --git a/support_files/api_docs/docs.go b/support_files/api_docs/docs.go
new file mode 100644
index 0000000..421d804
--- /dev/null
+++ b/support_files/api_docs/docs.go
@@ -0,0 +1,295 @@
+// Package api_docs Code generated by swaggo/swag. DO NOT EDIT
+package api_docs
+
+import "github.com/swaggo/swag"
+
+const docTemplate = `{
+ "schemes": {{ marshal .Schemes }},
+ "swagger": "2.0",
+ "info": {
+ "description": "{{escape .Description}}",
+ "title": "{{.Title}}",
+ "contact": {
+ "name": "OVINC CN",
+ "url": "https://github.com/OVINC-CN",
+ "email": "contact@ovinc.cn"
+ },
+ "license": {
+ "name": "MIT License",
+ "url": "https://github.com/OVINC-CN/APICenter/blob/main/LICENSE"
+ },
+ "version": "{{.Version}}"
+ },
+ "host": "{{.Host}}",
+ "basePath": "{{.BasePath}}",
+ "paths": {
+ "/v1/account/sign-in": {
+ "post": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Account"
+ ],
+ "parameters": [
+ {
+ "description": "request body",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/accountRouters.SignInRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/apiMixin.ResponseModel"
+ }
+ }
+ }
+ }
+ },
+ "/v1/account/sign-out": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Account"
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/apiMixin.ResponseModel"
+ }
+ }
+ }
+ }
+ },
+ "/v1/account/sign-up": {
+ "post": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Account"
+ ],
+ "parameters": [
+ {
+ "description": "request body",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/accountRouters.SignUpRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/apiMixin.ResponseModel"
+ }
+ }
+ }
+ }
+ },
+ "/v1/account/user-info": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Account"
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/accountRouters.UserInfoResponse"
+ }
+ }
+ }
+ }
+ },
+ "/v1/healthz": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Home"
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/apiMixin.ResponseModel"
+ }
+ }
+ }
+ }
+ },
+ "/v1/language": {
+ "put": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Home"
+ ],
+ "parameters": [
+ {
+ "description": "request body",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/homeRouters.ChangeLangRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/apiMixin.ResponseModel"
+ }
+ }
+ }
+ }
+ }
+ },
+ "definitions": {
+ "accountRouters.SignInRequest": {
+ "type": "object",
+ "required": [
+ "password",
+ "username"
+ ],
+ "properties": {
+ "password": {
+ "type": "string",
+ "maxLength": 64,
+ "minLength": 64
+ },
+ "username": {
+ "type": "string",
+ "maxLength": 32,
+ "minLength": 4
+ }
+ }
+ },
+ "accountRouters.SignUpRequest": {
+ "type": "object",
+ "required": [
+ "password",
+ "phone_number",
+ "username"
+ ],
+ "properties": {
+ "nickname": {
+ "type": "string",
+ "maxLength": 32,
+ "minLength": 4
+ },
+ "password": {
+ "type": "string",
+ "maxLength": 64,
+ "minLength": 64
+ },
+ "phone_number": {
+ "type": "string",
+ "maxLength": 11,
+ "minLength": 11
+ },
+ "username": {
+ "type": "string",
+ "maxLength": 32,
+ "minLength": 4
+ }
+ }
+ },
+ "accountRouters.UserInfoData": {
+ "type": "object",
+ "properties": {
+ "last_login": {
+ "type": "string"
+ },
+ "nick_name": {
+ "type": "string"
+ },
+ "user_id": {
+ "type": "integer"
+ },
+ "username": {
+ "type": "string"
+ }
+ }
+ },
+ "accountRouters.UserInfoResponse": {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/accountRouters.UserInfoData"
+ },
+ "message": {
+ "type": "string"
+ },
+ "trace_id": {
+ "type": "string"
+ }
+ }
+ },
+ "apiMixin.ResponseModel": {
+ "type": "object",
+ "properties": {
+ "data": {},
+ "message": {
+ "type": "string"
+ },
+ "trace_id": {
+ "type": "string"
+ }
+ }
+ },
+ "homeRouters.ChangeLangRequest": {
+ "type": "object",
+ "required": [
+ "lang"
+ ],
+ "properties": {
+ "lang": {
+ "type": "string",
+ "enum": [
+ "zh-Hans",
+ "en"
+ ]
+ }
+ }
+ }
+ }
+}`
+
+// SwaggerInfo holds exported Swagger Info so clients can modify it
+var SwaggerInfo = &swag.Spec{
+ Version: "2.0.0",
+ Host: "",
+ BasePath: "",
+ Schemes: []string{},
+ Title: "Api Center",
+ Description: "",
+ InfoInstanceName: "swagger",
+ SwaggerTemplate: docTemplate,
+ LeftDelim: "{{",
+ RightDelim: "}}",
+}
+
+func init() {
+ swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
+}
diff --git a/support_files/api_docs/swagger.json b/support_files/api_docs/swagger.json
new file mode 100644
index 0000000..a7f76ae
--- /dev/null
+++ b/support_files/api_docs/swagger.json
@@ -0,0 +1,268 @@
+{
+ "swagger": "2.0",
+ "info": {
+ "title": "Api Center",
+ "contact": {
+ "name": "OVINC CN",
+ "url": "https://github.com/OVINC-CN",
+ "email": "contact@ovinc.cn"
+ },
+ "license": {
+ "name": "MIT License",
+ "url": "https://github.com/OVINC-CN/APICenter/blob/main/LICENSE"
+ },
+ "version": "2.0.0"
+ },
+ "paths": {
+ "/v1/account/sign-in": {
+ "post": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Account"
+ ],
+ "parameters": [
+ {
+ "description": "request body",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/accountRouters.SignInRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/apiMixin.ResponseModel"
+ }
+ }
+ }
+ }
+ },
+ "/v1/account/sign-out": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Account"
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/apiMixin.ResponseModel"
+ }
+ }
+ }
+ }
+ },
+ "/v1/account/sign-up": {
+ "post": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Account"
+ ],
+ "parameters": [
+ {
+ "description": "request body",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/accountRouters.SignUpRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/apiMixin.ResponseModel"
+ }
+ }
+ }
+ }
+ },
+ "/v1/account/user-info": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Account"
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/accountRouters.UserInfoResponse"
+ }
+ }
+ }
+ }
+ },
+ "/v1/healthz": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Home"
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/apiMixin.ResponseModel"
+ }
+ }
+ }
+ }
+ },
+ "/v1/language": {
+ "put": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Home"
+ ],
+ "parameters": [
+ {
+ "description": "request body",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/homeRouters.ChangeLangRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/apiMixin.ResponseModel"
+ }
+ }
+ }
+ }
+ }
+ },
+ "definitions": {
+ "accountRouters.SignInRequest": {
+ "type": "object",
+ "required": [
+ "password",
+ "username"
+ ],
+ "properties": {
+ "password": {
+ "type": "string",
+ "maxLength": 64,
+ "minLength": 64
+ },
+ "username": {
+ "type": "string",
+ "maxLength": 32,
+ "minLength": 4
+ }
+ }
+ },
+ "accountRouters.SignUpRequest": {
+ "type": "object",
+ "required": [
+ "password",
+ "phone_number",
+ "username"
+ ],
+ "properties": {
+ "nickname": {
+ "type": "string",
+ "maxLength": 32,
+ "minLength": 4
+ },
+ "password": {
+ "type": "string",
+ "maxLength": 64,
+ "minLength": 64
+ },
+ "phone_number": {
+ "type": "string",
+ "maxLength": 11,
+ "minLength": 11
+ },
+ "username": {
+ "type": "string",
+ "maxLength": 32,
+ "minLength": 4
+ }
+ }
+ },
+ "accountRouters.UserInfoData": {
+ "type": "object",
+ "properties": {
+ "last_login": {
+ "type": "string"
+ },
+ "nick_name": {
+ "type": "string"
+ },
+ "user_id": {
+ "type": "integer"
+ },
+ "username": {
+ "type": "string"
+ }
+ }
+ },
+ "accountRouters.UserInfoResponse": {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/accountRouters.UserInfoData"
+ },
+ "message": {
+ "type": "string"
+ },
+ "trace_id": {
+ "type": "string"
+ }
+ }
+ },
+ "apiMixin.ResponseModel": {
+ "type": "object",
+ "properties": {
+ "data": {},
+ "message": {
+ "type": "string"
+ },
+ "trace_id": {
+ "type": "string"
+ }
+ }
+ },
+ "homeRouters.ChangeLangRequest": {
+ "type": "object",
+ "required": [
+ "lang"
+ ],
+ "properties": {
+ "lang": {
+ "type": "string",
+ "enum": [
+ "zh-Hans",
+ "en"
+ ]
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/support_files/api_docs/swagger.yaml b/support_files/api_docs/swagger.yaml
new file mode 100644
index 0000000..093a5f3
--- /dev/null
+++ b/support_files/api_docs/swagger.yaml
@@ -0,0 +1,175 @@
+definitions:
+ accountRouters.SignInRequest:
+ properties:
+ password:
+ maxLength: 64
+ minLength: 64
+ type: string
+ username:
+ maxLength: 32
+ minLength: 4
+ type: string
+ required:
+ - password
+ - username
+ type: object
+ accountRouters.SignUpRequest:
+ properties:
+ nickname:
+ maxLength: 32
+ minLength: 4
+ type: string
+ password:
+ maxLength: 64
+ minLength: 64
+ type: string
+ phone_number:
+ maxLength: 11
+ minLength: 11
+ type: string
+ username:
+ maxLength: 32
+ minLength: 4
+ type: string
+ required:
+ - password
+ - phone_number
+ - username
+ type: object
+ accountRouters.UserInfoData:
+ properties:
+ last_login:
+ type: string
+ nick_name:
+ type: string
+ user_id:
+ type: integer
+ username:
+ type: string
+ type: object
+ accountRouters.UserInfoResponse:
+ properties:
+ data:
+ $ref: '#/definitions/accountRouters.UserInfoData'
+ message:
+ type: string
+ trace_id:
+ type: string
+ type: object
+ apiMixin.ResponseModel:
+ properties:
+ data: {}
+ message:
+ type: string
+ trace_id:
+ type: string
+ type: object
+ homeRouters.ChangeLangRequest:
+ properties:
+ lang:
+ enum:
+ - zh-Hans
+ - en
+ type: string
+ required:
+ - lang
+ type: object
+info:
+ contact:
+ email: contact@ovinc.cn
+ name: OVINC CN
+ url: https://github.com/OVINC-CN
+ license:
+ name: MIT License
+ url: https://github.com/OVINC-CN/APICenter/blob/main/LICENSE
+ title: Api Center
+ version: 2.0.0
+paths:
+ /v1/account/sign-in:
+ post:
+ parameters:
+ - description: request body
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/accountRouters.SignInRequest'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/apiMixin.ResponseModel'
+ tags:
+ - Account
+ /v1/account/sign-out:
+ get:
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/apiMixin.ResponseModel'
+ tags:
+ - Account
+ /v1/account/sign-up:
+ post:
+ parameters:
+ - description: request body
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/accountRouters.SignUpRequest'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/apiMixin.ResponseModel'
+ tags:
+ - Account
+ /v1/account/user-info:
+ get:
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/accountRouters.UserInfoResponse'
+ tags:
+ - Account
+ /v1/healthz:
+ get:
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/apiMixin.ResponseModel'
+ tags:
+ - Home
+ /v1/language:
+ put:
+ parameters:
+ - description: request body
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/homeRouters.ChangeLangRequest'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/apiMixin.ResponseModel'
+ tags:
+ - Home
+swagger: "2.0"