Initial commit
This commit is contained in:
commit
95d68e9481
58
.air.toml
Normal file
58
.air.toml
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# Air Tomb Configuration for ULFlow Starter Kit
|
||||||
|
root = "."
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
# Just plain old shell command. You could use `make` as well.
|
||||||
|
cmd = "go build -o ./tmp/main.exe ./cmd/app"
|
||||||
|
# Binary file yields from `cmd`.
|
||||||
|
bin = "tmp/main.exe"
|
||||||
|
# Customize binary.
|
||||||
|
full_bin = "./tmp/main.exe"
|
||||||
|
# Watch these filename extensions.
|
||||||
|
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||||
|
# Ignore these filename extensions or directories.
|
||||||
|
exclude_dir = ["assets", "tmp", "vendor", ".git", "node_modules"]
|
||||||
|
# Watch these directories if you specified.
|
||||||
|
include_dir = []
|
||||||
|
# Exclude files.
|
||||||
|
exclude_file = []
|
||||||
|
# This log file places in your tmp_dir.
|
||||||
|
log = "air.log"
|
||||||
|
# It's not necessary to trigger build each time file changes if it's too frequent.
|
||||||
|
delay = 1000 # ms
|
||||||
|
# Stop running old binary when build errors occur.
|
||||||
|
stop_on_error = true
|
||||||
|
# Send Interrupt signal before killing process (windows does not support this feature)
|
||||||
|
send_interrupt = true
|
||||||
|
# Delay after sending Interrupt signal
|
||||||
|
kill_delay = 500 # ms
|
||||||
|
|
||||||
|
[log]
|
||||||
|
# Show log time
|
||||||
|
time = true
|
||||||
|
|
||||||
|
[color]
|
||||||
|
# Customize each part's color.
|
||||||
|
main = "magenta"
|
||||||
|
watcher = "cyan"
|
||||||
|
build = "yellow"
|
||||||
|
runner = "green"
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
# Delete tmp directory on exit
|
||||||
|
clean_on_exit = true
|
||||||
|
|
||||||
|
[screen]
|
||||||
|
clear_on_rebuild = true
|
||||||
|
keep_scroll = true
|
||||||
|
|
||||||
|
[tomb]
|
||||||
|
# Enable Tomb for graceful shutdown
|
||||||
|
enabled = true
|
||||||
|
# Kill signal to use for graceful shutdown
|
||||||
|
signal = "SIGTERM"
|
||||||
|
# Timeout for graceful shutdown
|
||||||
|
timeout = "5s"
|
||||||
|
# Path to the tomb config file
|
||||||
|
config = "./tomb.yaml"
|
||||||
62
.dockerignore
Normal file
62
.dockerignore
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# Version control
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
/bin/
|
||||||
|
/dist/
|
||||||
|
|
||||||
|
# IDE specific files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Environment files (except .env.example)
|
||||||
|
.env
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
*_test.go
|
||||||
|
/test/
|
||||||
|
/coverage.txt
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
/vendor/
|
||||||
|
/node_modules/
|
||||||
|
|
||||||
|
# Local development files
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# Build cache
|
||||||
|
.cache/
|
||||||
|
.air.toml
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# Local configuration overrides
|
||||||
|
configs/local.*
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
/docs/
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Ignore Dockerfile and docker-compose files (if you're building from source)
|
||||||
|
# !Dockerfile
|
||||||
|
# !docker-compose*.yml
|
||||||
32
.env.example
Normal file
32
.env.example
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# App Configuration
|
||||||
|
APP_NAME="ULFlow Starter Kit"
|
||||||
|
APP_VERSION="0.1.0"
|
||||||
|
APP_ENVIRONMENT="development"
|
||||||
|
APP_TIMEZONE="Asia/Ho_Chi_Minh"
|
||||||
|
|
||||||
|
# Logger Configuration
|
||||||
|
LOG_LEVEL="info" # debug, info, warn, error
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
SERVER_HOST="0.0.0.0"
|
||||||
|
SERVER_PORT=3000
|
||||||
|
SERVER_READ_TIMEOUT=15
|
||||||
|
SERVER_WRITE_TIMEOUT=15
|
||||||
|
SERVER_SHUTDOWN_TIMEOUT=30
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DB_DRIVER="postgres"
|
||||||
|
DB_HOST="localhost"
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USERNAME="postgres"
|
||||||
|
DB_PASSWORD="your_password_here"
|
||||||
|
DB_NAME="ulflow"
|
||||||
|
DB_SSLMODE="disable"
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET="your-32-byte-base64-encoded-secret-key-here"
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE=15 # in minutes
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRE=10080 # in minutes (7 days)
|
||||||
|
JWT_ALGORITHM="HS256"
|
||||||
|
JWT_ISSUER="ulflow-starter-kit"
|
||||||
|
JWT_AUDIENCE="ulflow-web"
|
||||||
5
.feature-flags.json
Normal file
5
.feature-flags.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enable_database": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
14
.gitea/commit-template.txt
Normal file
14
.gitea/commit-template.txt
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# <type>: <subject>
|
||||||
|
|
||||||
|
# <body>
|
||||||
|
|
||||||
|
# <footer>
|
||||||
|
|
||||||
|
# Types:
|
||||||
|
# feat (new feature)
|
||||||
|
# fix (bug fix)
|
||||||
|
# docs (documentation changes)
|
||||||
|
# style (formatting, no code change)
|
||||||
|
# refactor (refactoring code)
|
||||||
|
# test (adding tests, refactoring tests)
|
||||||
|
# chore (updating tasks etc; no production code change)
|
||||||
48
.gitea/hooks/pre-commit
Normal file
48
.gitea/hooks/pre-commit
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Pre-commit hook for ULFlow Golang Starter Kit
|
||||||
|
# This hook runs linters and checks before allowing a commit
|
||||||
|
|
||||||
|
echo "Running pre-commit checks..."
|
||||||
|
|
||||||
|
# Check for staged Go files
|
||||||
|
STAGED_GO_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep "\.go$")
|
||||||
|
|
||||||
|
if [[ "$STAGED_GO_FILES" = "" ]]; then
|
||||||
|
echo "No Go files staged for commit. Skipping Go-specific checks."
|
||||||
|
else
|
||||||
|
# Run golangci-lint on staged files
|
||||||
|
echo "Running golangci-lint..."
|
||||||
|
golangci-lint run $STAGED_GO_FILES
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "golangci-lint failed. Please fix the issues before committing."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for formatting issues
|
||||||
|
echo "Checking Go formatting..."
|
||||||
|
gofmt -l $STAGED_GO_FILES
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "gofmt failed. Please run 'gofmt -w' on your files before committing."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run tests related to staged files
|
||||||
|
echo "Running relevant tests..."
|
||||||
|
go test $(go list ./... | grep -v vendor) -short
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Tests failed. Please fix the tests before committing."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for sensitive information in staged files
|
||||||
|
echo "Checking for sensitive information..."
|
||||||
|
if git diff --cached | grep -E "(API_KEY|SECRET|PASSWORD|TOKEN).*[A-Za-z0-9_/-]{8,}" > /dev/null; then
|
||||||
|
echo "WARNING: Possible sensitive information detected in commit."
|
||||||
|
echo "Please verify you're not committing secrets, credentials or personal data."
|
||||||
|
echo "Press Enter to continue or Ctrl+C to abort the commit."
|
||||||
|
read
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Pre-commit checks passed!"
|
||||||
|
exit 0
|
||||||
34
.gitea/hooks/prepare-commit-msg
Normal file
34
.gitea/hooks/prepare-commit-msg
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# prepare-commit-msg hook for ULFlow Golang Starter Kit
|
||||||
|
# This hook prepares the commit message template
|
||||||
|
|
||||||
|
COMMIT_MSG_FILE=$1
|
||||||
|
COMMIT_SOURCE=$2
|
||||||
|
SHA1=$3
|
||||||
|
|
||||||
|
# Only add template if this is not a merge, amend, or rebase
|
||||||
|
if [ -z "$COMMIT_SOURCE" ]; then
|
||||||
|
# Check the current branch name to suggest commit type
|
||||||
|
BRANCH_NAME=$(git symbolic-ref --short HEAD)
|
||||||
|
|
||||||
|
# Extract type from branch name (e.g., feat/123-user-auth -> feat)
|
||||||
|
BRANCH_TYPE=$(echo $BRANCH_NAME | cut -d '/' -f 1)
|
||||||
|
|
||||||
|
# If branch follows naming convention, prepopulate commit message
|
||||||
|
if [[ "$BRANCH_TYPE" =~ ^(feat|fix|docs|style|refactor|test|chore)$ ]]; then
|
||||||
|
# Get the first line of the current commit message
|
||||||
|
first_line=$(head -n 1 $COMMIT_MSG_FILE)
|
||||||
|
|
||||||
|
# If the first line doesn't already have the type, prepend it
|
||||||
|
if [[ ! "$first_line" =~ ^$BRANCH_TYPE ]]; then
|
||||||
|
# Read existing commit msg
|
||||||
|
commit_msg=$(cat $COMMIT_MSG_FILE)
|
||||||
|
|
||||||
|
# Create new commit msg with suggested type
|
||||||
|
echo "$BRANCH_TYPE: " > $COMMIT_MSG_FILE
|
||||||
|
echo "$commit_msg" >> $COMMIT_MSG_FILE
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
126
.gitea/workflows/ci.yml
Normal file
126
.gitea/workflows/ci.yml
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
name: CI Pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches-ignore: [ main ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint
|
||||||
|
runs-on: ${{ secrets.RUNNER_LABEL || 'ubuntu-latest' }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.23'
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
uses: golangci/golangci-lint-action@v6
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
args: --timeout=5m
|
||||||
|
|
||||||
|
- name: Notify on failure
|
||||||
|
if: failure()
|
||||||
|
run: echo "::warning::Linting failed. Please fix code style issues."
|
||||||
|
|
||||||
|
security_scan:
|
||||||
|
name: Security Scan
|
||||||
|
runs-on: ${{ secrets.RUNNER_LABEL || 'ubuntu-latest' }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.23'
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
|
- name: Run Go Vulnerability Check
|
||||||
|
uses: golang/govulncheck-action@v1
|
||||||
|
|
||||||
|
- name: Notify on security issues
|
||||||
|
if: failure()
|
||||||
|
run: echo "::error::Security vulnerabilities detected. Please review dependencies."
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Test
|
||||||
|
runs-on: ${{ secrets.RUNNER_LABEL || 'ubuntu-latest' }}
|
||||||
|
needs: lint
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.23'
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
|
- name: Install go-junit-report
|
||||||
|
run: go install github.com/jstemmer/go-junit-report@latest
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: |
|
||||||
|
go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... | tee test-output.log
|
||||||
|
go tool cover -func=coverage.txt
|
||||||
|
|
||||||
|
- name: Generate test report
|
||||||
|
if: always()
|
||||||
|
run: cat test-output.log | go-junit-report > junit-report.xml
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
uses: codecov/codecov-action@v3
|
||||||
|
|
||||||
|
- name: Upload test report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: test-reports
|
||||||
|
path: junit-report.xml
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: ${{ secrets.RUNNER_LABEL || 'ubuntu-latest' }}
|
||||||
|
needs: [test, security_scan]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.23'
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
APP_VERSION="dev-${{ gitea.sha }}"
|
||||||
|
go build -v -ldflags="-s -w -X main.version=${APP_VERSION}" -o ./bin/app ./cmd/app
|
||||||
|
|
||||||
|
- name: Upload build artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: app-binary
|
||||||
|
path: ./bin/api
|
||||||
|
|
||||||
|
- name: Notify on success
|
||||||
|
if: success()
|
||||||
|
run: echo "::notice::Build successful. Ready for review and testing."
|
||||||
|
|
||||||
|
notify:
|
||||||
|
name: Notification
|
||||||
|
runs-on: ${{ secrets.RUNNER_LABEL || 'ubuntu-latest' }}
|
||||||
|
needs: [lint, test, security_scan, build]
|
||||||
|
if: always()
|
||||||
|
steps:
|
||||||
|
- name: Notify result
|
||||||
|
run: |
|
||||||
|
if [[ "${{ needs.build.result }}" == "success" ]]; then
|
||||||
|
echo "::notice::CI Pipeline completed successfully. Branch is ready for review."
|
||||||
|
else
|
||||||
|
echo "::warning::CI Pipeline failed. Please check the logs for details."
|
||||||
|
fi
|
||||||
135
.gitea/workflows/docker.yml
Normal file
135
.gitea/workflows/docker.yml
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
name: Docker Build and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: [ 'v*' ]
|
||||||
|
workflow_run:
|
||||||
|
#workflows: ["CI Pipeline"]
|
||||||
|
branches: [main]
|
||||||
|
types: [completed]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
name: Build and Push Docker Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') || github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ secrets.REGISTRY_URL }}
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ secrets.REGISTRY_URL }}/${{ gitea.repository }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=ref,event=branch
|
||||||
|
type=sha,format=short
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=${{ secrets.REGISTRY_URL }}/${{ gitea.repository }}:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ secrets.REGISTRY_URL }}/${{ gitea.repository }}:buildcache,mode=max
|
||||||
|
|
||||||
|
- name: Verify image
|
||||||
|
run: |
|
||||||
|
echo "Image successfully built and pushed with tags: ${{ steps.meta.outputs.tags }}"
|
||||||
|
echo "::notice::Docker image built and pushed successfully."
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy to VPS
|
||||||
|
runs-on: ${{ secrets.DEPLOY_RUNNER || 'ubuntu-latest' }}
|
||||||
|
needs: docker
|
||||||
|
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
steps:
|
||||||
|
- name: Set image name
|
||||||
|
run: echo "IMAGE_NAME=${{ secrets.REGISTRY_URL }}/${{ secrets.REPOSITORY_PATH }}:${{ gitea.ref_name || 'latest' }}" >> $GITEA_ENV
|
||||||
|
|
||||||
|
- name: Install Docker CLI
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y docker.io curl
|
||||||
|
docker version
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ secrets.REGISTRY_URL }}
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Deploy to VPS
|
||||||
|
run: |
|
||||||
|
echo "Deploying image: ${{ env.IMAGE_NAME }}"
|
||||||
|
|
||||||
|
# Pull latest image
|
||||||
|
docker pull ${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
|
# Stop and remove existing container
|
||||||
|
docker stop ulflow-api-container || true
|
||||||
|
docker rm ulflow-api-container || true
|
||||||
|
|
||||||
|
# Run new container
|
||||||
|
docker run -d \
|
||||||
|
--name ${{ secrets.CONTAINER_NAME || 'ulflow-api-container' }} \
|
||||||
|
--network ${{ secrets.DOCKER_NETWORK || 'ulflow-network' }} \
|
||||||
|
--restart always \
|
||||||
|
-p ${{ secrets.APP_PORT || '3000' }}:3000 \
|
||||||
|
-e APP_ENV=${{ secrets.APP_ENV || 'production' }} \
|
||||||
|
-e DB_HOST=${{ secrets.DB_HOST }} \
|
||||||
|
-e DB_USER=${{ secrets.DB_USER }} \
|
||||||
|
-e DB_PASSWORD=${{ secrets.DB_PASSWORD }} \
|
||||||
|
-e DB_NAME=${{ secrets.DB_NAME }} \
|
||||||
|
-e JWT_SECRET_KEY=${{ secrets.JWT_SECRET_KEY }} \
|
||||||
|
-e REFRESH_TOKEN_SECRET=${{ secrets.REFRESH_TOKEN_SECRET }} \
|
||||||
|
-e API_KEY=${{ secrets.API_KEY }} \
|
||||||
|
-e ENCRYPTION_KEY=${{ secrets.ENCRYPTION_KEY }} \
|
||||||
|
--health-cmd "${{ secrets.HEALTH_CMD || 'curl -f http://localhost:3000/health || exit 1' }}" \
|
||||||
|
--health-interval ${{ secrets.HEALTH_INTERVAL || '30s' }} \
|
||||||
|
--memory ${{ secrets.CONTAINER_MEMORY || '1g' }} \
|
||||||
|
--cpus ${{ secrets.CONTAINER_CPU || '1' }} \
|
||||||
|
${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
|
- name: Verify deployment
|
||||||
|
run: |
|
||||||
|
# Wait for container to start
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# Check container is running
|
||||||
|
if docker ps | grep ${{ secrets.CONTAINER_NAME || 'ulflow-api-container' }}; then
|
||||||
|
echo "Container is running"
|
||||||
|
else
|
||||||
|
echo "::error::Container failed to start"
|
||||||
|
docker logs ${{ secrets.CONTAINER_NAME || 'ulflow-api-container' }}
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check health endpoint
|
||||||
|
curl -f http://localhost:${{ secrets.APP_PORT || '3000' }}/health || (echo "::error::Health check failed" && exit 1)
|
||||||
|
|
||||||
|
echo "::notice::Deployment successful!"
|
||||||
|
|
||||||
|
- name: Send notification
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [[ "${{ job.status }}" == "success" ]]; then
|
||||||
|
echo "::notice::🚀 Deployment to VPS successful! Version: ${{ gitea.ref_name }}"
|
||||||
|
else
|
||||||
|
echo "::error::❌ Deployment to VPS failed! Check logs for details."
|
||||||
|
fi
|
||||||
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
bin/
|
||||||
|
tmp/
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories (remove the comment below to include it)
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
|
||||||
|
# IDE specific files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
|
||||||
|
# Air Tomb temporary files
|
||||||
|
.air.toml.tmp
|
||||||
|
|
||||||
|
# Docker volumes
|
||||||
|
docker/data/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# OS specific files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
102
Dockerfile
Normal file
102
Dockerfile
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
# syntax=docker/dockerfile:1.4
|
||||||
|
# Production-ready multi-stage build for server deployment
|
||||||
|
|
||||||
|
# Build arguments
|
||||||
|
ARG GO_VERSION=1.23.6
|
||||||
|
ARG ALPINE_VERSION=3.19
|
||||||
|
ARG APP_USER=appuser
|
||||||
|
ARG APP_GROUP=appgroup
|
||||||
|
|
||||||
|
# --- Builder Stage ---
|
||||||
|
FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS builder
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apk add --no-cache git make gcc libc-dev
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Enable Go modules
|
||||||
|
ENV GO111MODULE=on \
|
||||||
|
CGO_ENABLED=0 \
|
||||||
|
GOOS=linux \
|
||||||
|
GOARCH=amd64
|
||||||
|
|
||||||
|
# Copy dependency files first for better layer caching
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
|
||||||
|
# Download dependencies
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
|
||||||
|
# Create the configs directory in the build context
|
||||||
|
RUN mkdir -p /build/configs && \
|
||||||
|
cp -r configs/* /build/configs/ 2>/dev/null || :
|
||||||
|
|
||||||
|
# Build the application with optimizations
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
go build -ldflags="-w -s -X 'main.Version=$(git describe --tags --always 2>/dev/null || echo 'dev')'" \
|
||||||
|
-o /build/bin/app ./cmd/app
|
||||||
|
|
||||||
|
# --- Final Stage ---
|
||||||
|
FROM alpine:${ALPINE_VERSION}
|
||||||
|
|
||||||
|
# Re-declare ARG to use in this stage
|
||||||
|
ARG APP_USER=appuser
|
||||||
|
ARG APP_GROUP=appgroup
|
||||||
|
ARG APP_HOME=/app
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV TZ=Asia/Ho_Chi_Minh \
|
||||||
|
APP_USER=${APP_USER} \
|
||||||
|
APP_GROUP=${APP_GROUP} \
|
||||||
|
APP_HOME=${APP_HOME}
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
ca-certificates \
|
||||||
|
tzdata \
|
||||||
|
tini \
|
||||||
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
|
# Create app user and group
|
||||||
|
RUN addgroup -S ${APP_GROUP} && \
|
||||||
|
adduser -S -G ${APP_GROUP} -h ${APP_HOME} -D ${APP_USER}
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
RUN mkdir -p ${APP_HOME}/configs ${APP_HOME}/logs ${APP_HOME}/storage
|
||||||
|
|
||||||
|
# Switch to app directory
|
||||||
|
WORKDIR ${APP_HOME}
|
||||||
|
|
||||||
|
# Copy binary and configs from builder
|
||||||
|
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /build/bin/app .
|
||||||
|
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /build/configs ./configs/
|
||||||
|
|
||||||
|
# Set file permissions
|
||||||
|
RUN chmod +x ./app && \
|
||||||
|
chown -R ${APP_USER}:${APP_GROUP} ${APP_HOME}
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER ${APP_USER}
|
||||||
|
|
||||||
|
# Default command
|
||||||
|
CMD ["./app"]
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Set environment variable for production
|
||||||
|
ENV APP_ENV=production
|
||||||
|
|
||||||
|
# Command to run the application
|
||||||
|
ENTRYPOINT ["/sbin/tini", "--"]
|
||||||
32
Dockerfile.local
Normal file
32
Dockerfile.local
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Dockerfile.local
|
||||||
|
# Optimized for local development with hot reload
|
||||||
|
|
||||||
|
# Build stage
|
||||||
|
FROM golang:1.23-alpine AS builder
|
||||||
|
|
||||||
|
# Install necessary tools for development
|
||||||
|
RUN apk add --no-cache git make gcc libc-dev
|
||||||
|
|
||||||
|
# Install Air for hot reload
|
||||||
|
RUN go install github.com/cosmtrek/air@latest
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy go.mod and go.sum files
|
||||||
|
COPY go.mod go.sum* ./
|
||||||
|
|
||||||
|
# Download dependencies
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy the entire project
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Set environment variable for development
|
||||||
|
ENV APP_ENV=development
|
||||||
|
|
||||||
|
# Command to run the application with hot reload
|
||||||
|
CMD ["air", "-c", ".air.toml"]
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 ULFlow Team
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
198
Makefile
Normal file
198
Makefile
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
# ULFlow Golang Starter Kit Makefile
|
||||||
|
# Provides common commands for development, testing, and deployment
|
||||||
|
|
||||||
|
# Load environment variables from .env file
|
||||||
|
ifneq (,$(wildcard ./.env))
|
||||||
|
include .env
|
||||||
|
export
|
||||||
|
endif
|
||||||
|
|
||||||
|
.PHONY: help init dev test lint build clean docker-build docker-run docker-clean docker-prune docker-compose-up docker-compose-down docker-compose-prod-up docker-compose-prod-down ci setup-git all
|
||||||
|
|
||||||
|
# Default target executed when no arguments are given to make.
|
||||||
|
default: help
|
||||||
|
|
||||||
|
# Show help message for all Makefile commands
|
||||||
|
help:
|
||||||
|
@echo "ULFlow Golang Starter Kit"
|
||||||
|
@echo ""
|
||||||
|
@echo "Usage:"
|
||||||
|
@echo " make <target>"
|
||||||
|
@echo ""
|
||||||
|
@echo "Targets:"
|
||||||
|
@echo " init - Initialize project dependencies and tools"
|
||||||
|
@echo " dev - Start development server with hot reload (Air Tomb)"
|
||||||
|
@echo " test - Run all tests"
|
||||||
|
@echo " lint - Run linters and code quality tools"
|
||||||
|
@echo " build - Build the application binary"
|
||||||
|
@echo " clean - Clean temporary files, build artifacts, and cache"
|
||||||
|
@echo " docker-build - Build Docker image"
|
||||||
|
@echo " docker-run - Run application in Docker container"
|
||||||
|
@echo " docker-clean - Remove project Docker containers"
|
||||||
|
@echo " docker-prune - Remove all unused Docker resources"
|
||||||
|
@echo " docker-compose-up - Start all services with Docker Compose for local development"
|
||||||
|
@echo " docker-compose-down - Stop all services started with Docker Compose"
|
||||||
|
@echo " docker-compose-prod-up - Start all services with Docker Compose for production"
|
||||||
|
@echo " docker-compose-prod-down - Stop all production services"
|
||||||
|
@echo " ci - Run CI workflow locally"
|
||||||
|
@echo " setup-git - Configure Git with commit message template and hooks"
|
||||||
|
@echo " all - Run lint, test, and build"
|
||||||
|
|
||||||
|
# Initialize project dependencies and tools
|
||||||
|
init:
|
||||||
|
@echo "Installing project dependencies and tools..."
|
||||||
|
go mod tidy
|
||||||
|
go install github.com/cosmtrek/air@latest
|
||||||
|
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||||
|
@echo "Creating necessary directories..."
|
||||||
|
mkdir -p tmp
|
||||||
|
mkdir -p logs
|
||||||
|
|
||||||
|
@echo "Copying .env file if not exists..."
|
||||||
|
@if not exist .env (
|
||||||
|
if exist templates\.env.example (
|
||||||
|
copy templates\.env.example .env
|
||||||
|
@echo "Created .env file from example"
|
||||||
|
) else (
|
||||||
|
@echo "Warning: templates/.env.example not found, skipping .env creation"
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
@echo ".env file already exists, skipping"
|
||||||
|
)
|
||||||
|
|
||||||
|
@echo "Initialization complete!"
|
||||||
|
|
||||||
|
# Start development server with hot reload
|
||||||
|
dev:
|
||||||
|
@echo "Starting development server with Air Tomb..."
|
||||||
|
air
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
test:
|
||||||
|
@echo "Running tests..."
|
||||||
|
go test -v -cover ./...
|
||||||
|
|
||||||
|
# Run linters and code quality tools
|
||||||
|
lint:
|
||||||
|
@echo "Running linters..."
|
||||||
|
golangci-lint run ./...
|
||||||
|
|
||||||
|
# Build the application binary
|
||||||
|
build:
|
||||||
|
@echo "Building application..."
|
||||||
|
go build -o bin/app cmd/app/main.go
|
||||||
|
|
||||||
|
# Clean temporary files, build artifacts, and cache
|
||||||
|
clean:
|
||||||
|
@echo "Cleaning project..."
|
||||||
|
if exist tmp rmdir /s /q tmp
|
||||||
|
if exist bin rmdir /s /q bin
|
||||||
|
if exist logs rmdir /s /q logs
|
||||||
|
go clean -cache -testcache -modcache
|
||||||
|
@echo "Project cleaned!"
|
||||||
|
|
||||||
|
# Build Docker image
|
||||||
|
docker-build:
|
||||||
|
@echo "Building Docker image..."
|
||||||
|
docker build -t ulflow-starter-kit:latest .
|
||||||
|
|
||||||
|
# Run application in Docker container
|
||||||
|
docker-run:
|
||||||
|
@echo "Running application in Docker container..."
|
||||||
|
@if not exist .env (
|
||||||
|
@echo "Warning: .env file not found. Running with default environment variables..."
|
||||||
|
docker run -p 3000:3000 ulflow-starter-kit:latest
|
||||||
|
) else (
|
||||||
|
docker run -p 3000:3000 --env-file .env ulflow-starter-kit:latest
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run Docker Compose for local development
|
||||||
|
docker-compose-up:
|
||||||
|
@echo "Starting all services with Docker Compose for local development..."
|
||||||
|
docker-compose up -d
|
||||||
|
@echo "Services started! API is available at http://localhost:3000"
|
||||||
|
|
||||||
|
# Stop Docker Compose services for local development
|
||||||
|
docker-compose-down:
|
||||||
|
@echo "Stopping all development services..."
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Run Docker Compose for production
|
||||||
|
docker-compose-prod-up:
|
||||||
|
@echo "Starting all services with Docker Compose for production..."
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d
|
||||||
|
@echo "Production services started! API is available at http://localhost:3000"
|
||||||
|
|
||||||
|
# Stop Docker Compose services for production
|
||||||
|
docker-compose-prod-down:
|
||||||
|
@echo "Stopping all production services..."
|
||||||
|
docker-compose -f docker-compose.prod.yml down
|
||||||
|
|
||||||
|
# Setup Git configuration
|
||||||
|
setup-git-hooks:
|
||||||
|
@echo "Setting up Git hooks..."
|
||||||
|
git config --local commit.template .gitea/commit-template.txt
|
||||||
|
if not exist .git/hooks mkdir .git/hooks
|
||||||
|
copy /Y .gitea\hooks\pre-commit .git\hooks\ >nul
|
||||||
|
copy /Y .gitea\hooks\prepare-commit-msg .git\hooks\ >nul
|
||||||
|
git config --local core.hooksPath .git/hooks
|
||||||
|
@echo "Git setup complete!"
|
||||||
|
|
||||||
|
# Create git message template
|
||||||
|
setup-git-message:
|
||||||
|
@echo "Creating Git commit message template..."
|
||||||
|
if not exist .gitea mkdir .gitea
|
||||||
|
mkdir -p .gitea
|
||||||
|
echo "# <type>: <subject>\n\n# <body>\n\n# <footer>\n\n# Types:\n# feat (new feature)\n# fix (bug fix)\n# docs (documentation changes)\n# style (formatting, no code change)\n# refactor (refactoring code)\n# test (adding tests, refactoring tests)\n# chore (updating tasks etc; no production code change)" > .gitea/commit-template.txt
|
||||||
|
git config --local commit.template .gitea/commit-template.txt
|
||||||
|
@echo "Git commit message template created!"
|
||||||
|
|
||||||
|
|
||||||
|
# Prune all unused Docker resources
|
||||||
|
docker-prune:
|
||||||
|
@echo "Pruning unused Docker resources..."
|
||||||
|
docker system prune -af --volumes
|
||||||
|
@echo "Docker resources pruned!"
|
||||||
|
|
||||||
|
# Run local CI simulation
|
||||||
|
ci:
|
||||||
|
@echo "Running CI workflow locally..."
|
||||||
|
make lint
|
||||||
|
make test
|
||||||
|
make build
|
||||||
|
make docker-build
|
||||||
|
@echo "CI simulation completed!"
|
||||||
|
|
||||||
|
# Create a new migration
|
||||||
|
migrate-create:
|
||||||
|
@read -p "Enter migration name: " name; \
|
||||||
|
migrate create -ext sql -dir migrations -seq $$name
|
||||||
|
|
||||||
|
# Run migrations up
|
||||||
|
m-up:
|
||||||
|
@echo "Running migrations with user: $(DATABASE_USERNAME)"
|
||||||
|
@echo "Database: $(DATABASE_NAME) on $(DATABASE_HOST):$(DATABASE_PORT)"
|
||||||
|
@echo "Using connection string: postgres://$(DATABASE_USERNAME):*****@$(DATABASE_HOST):$(DATABASE_PORT)/$(DATABASE_NAME)?sslmode=disable"
|
||||||
|
@migrate -path migrations -database "postgres://$(DATABASE_USERNAME):$(DATABASE_PASSWORD)@$(DATABASE_HOST):$(DATABASE_PORT)/$(DATABASE_NAME)?sslmode=disable" up
|
||||||
|
|
||||||
|
# Run migrations down
|
||||||
|
m-down:
|
||||||
|
@echo "Reverting migrations..."
|
||||||
|
@migrate -path migrations -database "postgres://$(DATABASE_USERNAME):$(DATABASE_PASSWORD)@$(DATABASE_HOST):$(DATABASE_PORT)/$(DATABASE_NAME)?sslmode=disable" down
|
||||||
|
|
||||||
|
# Reset database (drop all tables and re-run migrations)
|
||||||
|
m-reset: m-down m-up
|
||||||
|
@echo "Database reset complete!"
|
||||||
|
|
||||||
|
# Show migration status
|
||||||
|
m-status:
|
||||||
|
@migrate -path migrations -database "postgres://$(DATABASE_USERNAME):$(DATABASE_PASSWORD)@$(DATABASE_HOST):$(DATABASE_PORT)/$(DATABASE_NAME)?sslmode=disable" version
|
||||||
|
|
||||||
|
# Force migration to specific version (fix dirty state)
|
||||||
|
m-force:
|
||||||
|
@echo "Forcing migration to version $(version)..."
|
||||||
|
@migrate -path migrations -database "postgres://$(DATABASE_USERNAME):$(DATABASE_PASSWORD)@$(DATABASE_HOST):$(DATABASE_PORT)/$(DATABASE_NAME)?sslmode=disable" force $(version)
|
||||||
|
|
||||||
|
# Run application (default: without hot reload)
|
||||||
|
run:
|
||||||
|
go run ./cmd/app/main.go
|
||||||
192
Readme.md
Normal file
192
Readme.md
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
# ULFlow Golang Starter Kit
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[](https://go.dev/)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Tổng quan
|
||||||
|
|
||||||
|
ULFlow Golang Starter Kit là nền tảng khởi tạo dự án backend cho Team ULFlow, được thiết kế để giúp khởi tạo các dự án trong thời gian ngắn, nhanh chóng mà vẫn đảm bảo các yếu tố cơ bản về kiến trúc, bảo mật và khả năng mở rộng.
|
||||||
|
|
||||||
|
### Đặc điểm chính
|
||||||
|
|
||||||
|
- **Kiến trúc DDD (Domain Driven Development)** - Customize cho phù hợp với nhu cầu dự án
|
||||||
|
- **U-Hierarchy** - Mô hình tổ chức mã nguồn theo các cấp độ logic
|
||||||
|
- **Hot Reload** - Phát triển nhanh với Air Tomb
|
||||||
|
- **Docker & Docker Compose** - Containerization và orchestration
|
||||||
|
- **Gitea Full** - CI/CD, Secret Manager, Registry Image
|
||||||
|
- **Postgres** - Cơ sở dữ liệu quan hệ
|
||||||
|
|
||||||
|
## Cài đặt
|
||||||
|
|
||||||
|
### Yêu cầu hệ thống
|
||||||
|
|
||||||
|
- Go 1.23
|
||||||
|
- Docker và Docker Compose
|
||||||
|
- Git
|
||||||
|
|
||||||
|
### Khởi tạo dự án
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone repository
|
||||||
|
git clone [repo-url] my-project
|
||||||
|
cd my-project
|
||||||
|
|
||||||
|
# Cài đặt dependencies và công cụ
|
||||||
|
make init
|
||||||
|
|
||||||
|
# Cấu hình môi trường
|
||||||
|
cp .env.example .env
|
||||||
|
# Chỉnh sửa file .env theo nhu cầu
|
||||||
|
|
||||||
|
# Khởi động môi trường phát triển
|
||||||
|
make dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sử dụng Docker
|
||||||
|
|
||||||
|
Dự án cung cấp hai cấu hình Docker:
|
||||||
|
|
||||||
|
#### Môi trường phát triển (Local)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Khởi động tất cả các dịch vụ với Docker Compose
|
||||||
|
make docker-compose-up
|
||||||
|
|
||||||
|
# Dừng tất cả các dịch vụ
|
||||||
|
make docker-compose-down
|
||||||
|
|
||||||
|
# Dọn dẹp container sau khi sử dụng
|
||||||
|
make docker-clean
|
||||||
|
```
|
||||||
|
|
||||||
|
Môi trường phát triển bao gồm:
|
||||||
|
- API service với hot reload (Air Tomb)
|
||||||
|
- PostgreSQL database
|
||||||
|
- Gitea (Git Server, CI/CD, Registry)
|
||||||
|
- Gitea Runner
|
||||||
|
|
||||||
|
#### Môi trường production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Khởi động các dịch vụ cho production
|
||||||
|
make docker-compose-prod-up
|
||||||
|
|
||||||
|
# Dừng các dịch vụ production
|
||||||
|
make docker-compose-prod-down
|
||||||
|
```
|
||||||
|
|
||||||
|
Môi trường production bao gồm:
|
||||||
|
- API service (optimized build)
|
||||||
|
- PostgreSQL database
|
||||||
|
- Nginx reverse proxy
|
||||||
|
|
||||||
|
## Kiến trúc
|
||||||
|
|
||||||
|
Dự án sử dụng mô hình DDD (Domain Driven Development) với các thành phần chính:
|
||||||
|
|
||||||
|
- **Resource**: Các Aggregate DDD
|
||||||
|
- **Transaction**: Các Saga điều phối luồng nghiệp vụ phức tạp
|
||||||
|
- **Adapter**: Xử lý giao tiếp với các hệ thống bên ngoài
|
||||||
|
- **Helper**: Các thư viện, tiện ích dùng chung
|
||||||
|
- **UIUX**: Lớp giao diện người dùng
|
||||||
|
|
||||||
|
Thành phần kiến trúc chi tiết (U-Hierarchy):
|
||||||
|
|
||||||
|
- **ubit**: Đơn vị logic nhỏ nhất (hàm, type, hằng số)
|
||||||
|
- **ubrick**: Tập hợp các `ubit` liên quan
|
||||||
|
- **ublock**: Thành phần hoạt động độc lập tương đối
|
||||||
|
- **ubundle**: Tính năng hoàn chỉnh cho người dùng
|
||||||
|
|
||||||
|
## Cấu trúc thư mục
|
||||||
|
|
||||||
|
```
|
||||||
|
├── cmd/ # Entry points
|
||||||
|
├── pkg/ # Packages
|
||||||
|
│ ├── resource/ # Domain resources (DDD Aggregates)
|
||||||
|
│ ├── transaction/ # Business workflows
|
||||||
|
│ ├── adapter/ # External system interfaces
|
||||||
|
│ ├── helper/ # Utilities
|
||||||
|
│ └── uiux/ # User interfaces
|
||||||
|
├── config/ # Configuration
|
||||||
|
├── docs/ # Documentation
|
||||||
|
├── scripts/ # Helper scripts
|
||||||
|
└── test/ # Tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Các lệnh Makefile
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Xem danh sách lệnh có sẵn
|
||||||
|
make help
|
||||||
|
|
||||||
|
# Khởi tạo dự án
|
||||||
|
make init
|
||||||
|
|
||||||
|
# Chạy môi trường phát triển với Air Tomb
|
||||||
|
make dev
|
||||||
|
|
||||||
|
# Chạy tests
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Kiểm tra linting
|
||||||
|
make lint
|
||||||
|
|
||||||
|
# Build ứng dụng
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Dọn dẹp tệp tạm
|
||||||
|
make clean
|
||||||
|
|
||||||
|
# Docker commands
|
||||||
|
make docker-build # Build Docker image
|
||||||
|
make docker-run # Run container từ image
|
||||||
|
make docker-clean # Dọn dẹp container
|
||||||
|
make docker-prune # Dọn dẹp tất cả tài nguyên Docker không sử dụng
|
||||||
|
make docker-compose-up # Khởi động môi trường phát triển
|
||||||
|
make docker-compose-down # Dừng môi trường phát triển
|
||||||
|
make docker-compose-prod-up # Khởi động môi trường production
|
||||||
|
make docker-compose-prod-down # Dừng môi trường production
|
||||||
|
|
||||||
|
# Cấu hình Git
|
||||||
|
make setup-git
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quy trình phát triển
|
||||||
|
|
||||||
|
Dự án sử dụng Trunk-Based Development với các quy ước đặt tên nhánh:
|
||||||
|
|
||||||
|
- `feat`: Tạo tính năng mới
|
||||||
|
- `fix`: Sửa lỗi
|
||||||
|
- `docs`: Thay đổi tài liệu
|
||||||
|
- `style`: Thay đổi không ảnh hưởng đến logic
|
||||||
|
- `refactor`: Refactor code
|
||||||
|
- `test`: Thêm hoặc sửa test
|
||||||
|
- `chore`: Thay đổi cấu hình hoặc các task không liên quan đến code
|
||||||
|
|
||||||
|
Xem thêm chi tiết trong [docs/workflow.md](docs/workflow.md).
|
||||||
|
|
||||||
|
## Tài liệu
|
||||||
|
|
||||||
|
- [Kiến trúc](docs/architecture.md)
|
||||||
|
- [Thông số kỹ thuật](docs/spec.md)
|
||||||
|
- [Roadmap](docs/roadmap.md)
|
||||||
|
- [Quy trình làm việc](docs/workflow.md)
|
||||||
|
- [CI/CD Workflows](docs/workflows.md)
|
||||||
|
- [Testing](docs/testing.md)
|
||||||
|
- [Adapter](docs/adapter.md)
|
||||||
|
- [UX](docs/ux.md)
|
||||||
|
- [Changelog](docs/changelog.md)
|
||||||
|
|
||||||
|
## Đóng góp
|
||||||
|
|
||||||
|
Xem hướng dẫn đóng góp trong [docs/workflow.md](docs/workflow.md).
|
||||||
|
|
||||||
|
## Giấy phép
|
||||||
|
|
||||||
|
Dự án này được phân phối dưới Giấy phép MIT. Xem file `LICENSE` để biết thêm thông tin.
|
||||||
176
cmd/app/main.go
Normal file
176
cmd/app/main.go
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"starter-kit/internal/helper/config"
|
||||||
|
"starter-kit/internal/helper/database"
|
||||||
|
"starter-kit/internal/helper/feature"
|
||||||
|
"starter-kit/internal/helper/logger"
|
||||||
|
"starter-kit/internal/pkg/lifecycle"
|
||||||
|
"starter-kit/internal/transport/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPService implements the lifecycle.Service interface for the HTTP server
|
||||||
|
type HTTPService struct {
|
||||||
|
server *http.Server
|
||||||
|
cfg *config.Config
|
||||||
|
db *database.Database
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPService(cfg *config.Config, db *database.Database) *HTTPService {
|
||||||
|
return &HTTPService{
|
||||||
|
server: http.NewServer(cfg, db.DB),
|
||||||
|
cfg: cfg,
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HTTPService) Name() string {
|
||||||
|
return "HTTP Server"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HTTPService) Start() error {
|
||||||
|
// Tạo channel để nhận lỗi từ goroutine
|
||||||
|
errChan := make(chan error, 1)
|
||||||
|
|
||||||
|
// Khởi động server trong goroutine
|
||||||
|
go func() {
|
||||||
|
logger.Infof("Đang khởi động %s trên %s:%d...", s.Name(), s.cfg.Server.Host, s.cfg.Server.Port)
|
||||||
|
if err := s.server.Start(); err != nil {
|
||||||
|
logger.WithError(err).Error("Lỗi HTTP server")
|
||||||
|
errChan <- fmt.Errorf("lỗi HTTP server: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errChan <- nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Chờ server khởi động hoặc báo lỗi
|
||||||
|
select {
|
||||||
|
case err := <-errChan:
|
||||||
|
return err // Trả về lỗi nếu có
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
// Nếu sau 5 giây không có lỗi, coi như server đã khởi động thành công
|
||||||
|
logger.Infof("%s đã khởi động thành công trên %s:%d", s.Name(), s.cfg.Server.Host, s.cfg.Server.Port)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HTTPService) Shutdown(ctx context.Context) error {
|
||||||
|
return s.server.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Initialize config loader
|
||||||
|
configLoader := config.NewConfigLoader()
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
cfg, err := configLoader.Load()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to load configuration: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize logger
|
||||||
|
logger.Init(&logger.LogConfig{
|
||||||
|
Level: cfg.Logger.Level,
|
||||||
|
Format: "json",
|
||||||
|
EnableCaller: true,
|
||||||
|
ReportCaller: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize feature flags
|
||||||
|
if err := feature.Init(); err != nil {
|
||||||
|
logger.WithError(err).Fatal("Failed to initialize feature flags")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print application info
|
||||||
|
logger.Infof("Starting %s v%s", cfg.App.Name, cfg.App.Version)
|
||||||
|
logger.Infof("Environment: %s", cfg.App.Environment)
|
||||||
|
logger.Infof("Log Level: %s", cfg.Logger.Level)
|
||||||
|
logger.Infof("Timezone: %s", cfg.App.Timezone)
|
||||||
|
|
||||||
|
// Print server config
|
||||||
|
logger.Infof("Server config: %s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||||
|
logger.Infof("Read Timeout: %d seconds", cfg.Server.ReadTimeout)
|
||||||
|
logger.Infof("Write Timeout: %d seconds", cfg.Server.WriteTimeout)
|
||||||
|
logger.Infof("Shutdown Timeout: %d seconds", cfg.Server.ShutdownTimeout)
|
||||||
|
|
||||||
|
// Create a new lifecycle manager
|
||||||
|
shutdownTimeout := 30 * time.Second // Default shutdown timeout
|
||||||
|
if cfg.Server.ShutdownTimeout > 0 {
|
||||||
|
shutdownTimeout = time.Duration(cfg.Server.ShutdownTimeout) * time.Second
|
||||||
|
}
|
||||||
|
lifecycleMgr := lifecycle.New(shutdownTimeout)
|
||||||
|
|
||||||
|
// Initialize database connection
|
||||||
|
db, err := database.NewConnection(&cfg.Database)
|
||||||
|
if err != nil {
|
||||||
|
logger.WithError(err).Fatal("Failed to connect to database")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run database migrations
|
||||||
|
if err := database.Migrate(cfg.Database); err != nil {
|
||||||
|
logger.WithError(err).Fatal("Failed to migrate database")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register database cleanup on shutdown
|
||||||
|
lifecycleMgr.Register(&databaseService{db: db})
|
||||||
|
|
||||||
|
// Initialize HTTP service with database
|
||||||
|
httpService := NewHTTPService(cfg, &database.Database{DB: db})
|
||||||
|
if httpService == nil {
|
||||||
|
logger.Fatal("Failed to create HTTP service")
|
||||||
|
}
|
||||||
|
lifecycleMgr.Register(httpService)
|
||||||
|
|
||||||
|
// Start all services
|
||||||
|
logger.Info("Đang khởi động các dịch vụ...")
|
||||||
|
if err := lifecycleMgr.Start(); err != nil {
|
||||||
|
logger.WithError(err).Fatal("Lỗi nghiêm trọng: Không thể khởi động các dịch vụ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle OS signals for graceful shutdown in a separate goroutine
|
||||||
|
go lifecycleMgr.ShutdownOnSignal()
|
||||||
|
|
||||||
|
// Wait for all services to complete
|
||||||
|
if err := lifecycleMgr.Wait(); err != nil {
|
||||||
|
logger.WithError(err).Error("Service error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close feature flags
|
||||||
|
if err := feature.Close(); err != nil {
|
||||||
|
logger.WithError(err).Error("Failed to close feature flags")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Application stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// databaseService implements the lifecycle.Service interface for database operations
|
||||||
|
type databaseService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *databaseService) Name() string {
|
||||||
|
return "Database Service"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *databaseService) Start() error {
|
||||||
|
// Database initialization is handled in main
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *databaseService) Shutdown(ctx context.Context) error {
|
||||||
|
if s.db != nil {
|
||||||
|
sqlDB, err := s.db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get database instance: %w", err)
|
||||||
|
}
|
||||||
|
return sqlDB.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
76
configs/config.yaml
Normal file
76
configs/config.yaml
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
app:
|
||||||
|
name: "ULFlow Starter Kit"
|
||||||
|
version: "0.1.0"
|
||||||
|
environment: "development"
|
||||||
|
timezone: "Asia/Ho_Chi_Minh"
|
||||||
|
|
||||||
|
logger:
|
||||||
|
level: "info" # debug, info, warn, error
|
||||||
|
|
||||||
|
server:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 3000
|
||||||
|
read_timeout: 15
|
||||||
|
write_timeout: 15
|
||||||
|
shutdown_timeout: 30
|
||||||
|
trusted_proxies: []
|
||||||
|
allow_origins:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
database:
|
||||||
|
driver: "postgres"
|
||||||
|
host: "localhost"
|
||||||
|
port: 5432
|
||||||
|
username: "postgres"
|
||||||
|
password: "postgres"
|
||||||
|
database: "ulflow"
|
||||||
|
ssl_mode: "disable"
|
||||||
|
max_open_conns: 25
|
||||||
|
max_idle_conns: 5
|
||||||
|
conn_max_lifetime: 300
|
||||||
|
migration_path: "migrations"
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
jwt:
|
||||||
|
# Generate a secure random secret key using: openssl rand -base64 32
|
||||||
|
secret: "ulflow2121_this_is_a_secure_key_for_jwt_signing"
|
||||||
|
# Access Token expiration time in minutes (15 minutes)
|
||||||
|
access_token_expire: 15
|
||||||
|
# Refresh Token expiration time in minutes (7 days = 10080 minutes)
|
||||||
|
refresh_token_expire: 10080
|
||||||
|
# Algorithm for JWT signing (HS256, HS384, HS512, RS256, etc.)
|
||||||
|
algorithm: "HS256"
|
||||||
|
# Issuer for JWT tokens
|
||||||
|
issuer: "ulflow-starter-kit"
|
||||||
|
# Audience for JWT tokens
|
||||||
|
audience: ["ulflow-web"]
|
||||||
|
|
||||||
|
# Security configurations
|
||||||
|
security:
|
||||||
|
# Rate limiting for authentication endpoints (requests per minute)
|
||||||
|
rate_limit:
|
||||||
|
login: 5
|
||||||
|
register: 3
|
||||||
|
refresh: 10
|
||||||
|
# Password policy
|
||||||
|
password:
|
||||||
|
min_length: 8
|
||||||
|
require_upper: true
|
||||||
|
require_lower: true
|
||||||
|
require_number: true
|
||||||
|
require_special: true
|
||||||
|
# Cookie settings
|
||||||
|
cookie:
|
||||||
|
secure: true
|
||||||
|
http_only: true
|
||||||
|
same_site: "Lax" # or "Strict" for more security
|
||||||
|
domain: "" # Set your domain in production
|
||||||
|
path: "/"
|
||||||
|
# CORS settings
|
||||||
|
cors:
|
||||||
|
allowed_origins: ["*"] # Restrict in production
|
||||||
|
allowed_methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||||
|
allowed_headers: ["Origin", "Content-Type", "Accept", "Authorization"]
|
||||||
|
exposed_headers: ["Content-Length", "X-Total-Count"]
|
||||||
|
allow_credentials: true
|
||||||
|
max_age: 300 # 5 minutes
|
||||||
89
configs/security.example.yaml
Normal file
89
configs/security.example.yaml
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
# Cấu hình bảo mật cho ứng dụng
|
||||||
|
|
||||||
|
# Cấu hình CORS
|
||||||
|
cors:
|
||||||
|
# Danh sách các domain được phép truy cập (sử dụng "*" để cho phép tất cả)
|
||||||
|
allowed_origins:
|
||||||
|
- "https://example.com"
|
||||||
|
- "https://api.example.com"
|
||||||
|
|
||||||
|
# Các phương thức HTTP được phép
|
||||||
|
allowed_methods:
|
||||||
|
- GET
|
||||||
|
- POST
|
||||||
|
- PUT
|
||||||
|
- PATCH
|
||||||
|
- DELETE
|
||||||
|
- OPTIONS
|
||||||
|
|
||||||
|
# Các header được phép
|
||||||
|
allowed_headers:
|
||||||
|
- Origin
|
||||||
|
- Content-Type
|
||||||
|
- Content-Length
|
||||||
|
- Accept-Encoding
|
||||||
|
- X-CSRF-Token
|
||||||
|
- Authorization
|
||||||
|
- X-Requested-With
|
||||||
|
- X-Request-ID
|
||||||
|
|
||||||
|
# Các header được phép hiển thị
|
||||||
|
exposed_headers:
|
||||||
|
- Content-Length
|
||||||
|
- X-Total-Count
|
||||||
|
|
||||||
|
# Cho phép gửi credentials (cookie, authorization headers)
|
||||||
|
allow_credentials: true
|
||||||
|
|
||||||
|
# Thời gian cache preflight request (ví dụ: 5m, 1h)
|
||||||
|
max_age: 5m
|
||||||
|
|
||||||
|
# Bật chế độ debug
|
||||||
|
debug: false
|
||||||
|
|
||||||
|
# Cấu hình Rate Limiting
|
||||||
|
rate_limit:
|
||||||
|
# Số request tối đa trong khoảng thời gian
|
||||||
|
rate: 100
|
||||||
|
|
||||||
|
# Khoảng thời gian (ví dụ: 1m, 5m, 1h)
|
||||||
|
window: 1m
|
||||||
|
|
||||||
|
# Danh sách các route được bỏ qua rate limiting
|
||||||
|
excluded_routes:
|
||||||
|
- "/health"
|
||||||
|
- "/metrics"
|
||||||
|
|
||||||
|
# Cấu hình Security Headers
|
||||||
|
headers:
|
||||||
|
# Bật/tắt security headers
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Chính sách bảo mật nội dung (Content Security Policy)
|
||||||
|
content_security_policy: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'"
|
||||||
|
|
||||||
|
# Chính sách bảo mật truy cập tài nguyên (Cross-Origin)
|
||||||
|
cross_origin_resource_policy: "same-origin"
|
||||||
|
cross_origin_opener_policy: "same-origin"
|
||||||
|
cross_origin_embedder_policy: "require-corp"
|
||||||
|
|
||||||
|
# Chính sách tham chiếu (Referrer-Policy)
|
||||||
|
referrer_policy: "no-referrer-when-downgrade"
|
||||||
|
|
||||||
|
# Chính sách sử dụng các tính năng trình duyệt (Feature-Policy)
|
||||||
|
feature_policy: "geolocation 'none'; midi 'none'; notifications 'none'; push 'none'; sync-xhr 'none'; microphone 'none'; camera 'none'; magnetometer 'none'; gyroscope 'none'; speaker 'none'; vibrate 'none'; fullscreen 'none'; payment 'none'"
|
||||||
|
|
||||||
|
# Chính sách bảo vệ clickjacking (X-Frame-Options)
|
||||||
|
frame_options: "DENY"
|
||||||
|
|
||||||
|
# Chính sách bảo vệ XSS (X-XSS-Protection)
|
||||||
|
xss_protection: "1; mode=block"
|
||||||
|
|
||||||
|
# Chính sách MIME type sniffing (X-Content-Type-Options)
|
||||||
|
content_type_options: "nosniff"
|
||||||
|
|
||||||
|
# Chính sách Strict-Transport-Security (HSTS)
|
||||||
|
strict_transport_security: "max-age=31536000; includeSubDomains; preload"
|
||||||
|
|
||||||
|
# Chính sách Permissions-Policy (thay thế cho Feature-Policy)
|
||||||
|
permissions_policy: "geolocation=(), microphone=(), camera=()"
|
||||||
24
coverage
Normal file
24
coverage
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
mode: set
|
||||||
|
starter-kit/internal/transport/http/handler/auth_handler.go:18.63,22.2 1 1
|
||||||
|
starter-kit/internal/transport/http/handler/auth_handler.go:36.48,38.47 2 1
|
||||||
|
starter-kit/internal/transport/http/handler/auth_handler.go:38.47,41.3 2 1
|
||||||
|
starter-kit/internal/transport/http/handler/auth_handler.go:44.2,45.16 2 1
|
||||||
|
starter-kit/internal/transport/http/handler/auth_handler.go:45.16,47.54 1 1
|
||||||
|
starter-kit/internal/transport/http/handler/auth_handler.go:47.54,49.4 1 0
|
||||||
|
starter-kit/internal/transport/http/handler/auth_handler.go:49.9,51.4 1 1
|
||||||
|
starter-kit/internal/transport/http/handler/auth_handler.go:52.3,52.9 1 1
|
||||||
|
starter-kit/internal/transport/http/handler/auth_handler.go:56.2,57.42 2 1
|
||||||
|
starter-kit/internal/transport/http/handler/auth_handler.go:72.45,74.47 2 1
|
||||||
|
starter-kit/internal/transport/http/handler/auth_handler.go:74.47,77.3 2 0
|
||||||
|
starter-kit/internal/transport/http/handler/auth_handler.go:80.2,81.16 2 1
|
||||||
|
starter-kit/internal/transport/http/handler/auth_handler.go:81.16,84.3 2 1
|
||||||
|
starter-kit/internal/transport/http/handler/auth_handler.go:87.2,95.33 3 0
|
||||||
|
starter-kit/internal/transport/http/handler/auth_handler.go:109.52,115.47 2 1
|
||||||
|
starter-kit/internal/transport/http/handler/auth_handler.go:115.47,118.3 2 1
|
||||||
|
starter-kit/internal/transport/http/handler/auth_handler.go:121.2,122.16 2 0
|
||||||
|
starter-kit/internal/transport/http/handler/auth_handler.go:122.16,125.3 2 0
|
||||||
|
starter-kit/internal/transport/http/handler/auth_handler.go:128.2,136.33 3 0
|
||||||
|
starter-kit/internal/transport/http/handler/auth_handler.go:146.46,149.2 1 0
|
||||||
|
starter-kit/internal/transport/http/handler/health_handler.go:19.58,25.2 1 1
|
||||||
|
starter-kit/internal/transport/http/handler/health_handler.go:34.53,55.2 3 1
|
||||||
|
starter-kit/internal/transport/http/handler/health_handler.go:64.46,70.2 1 1
|
||||||
83
docker-compose.prod.yml
Normal file
83
docker-compose.prod.yml
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# API Service - Production Configuration
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: ulflow-api
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- APP_ENV=production
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
networks:
|
||||||
|
- ulflow-network
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '1'
|
||||||
|
memory: 1G
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
max_attempts: 3
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
# PostgreSQL Database - Production Configuration
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: ulflow-postgres
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${DB_USER:-user}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
|
||||||
|
POSTGRES_DB: ${DB_NAME:-ulflow_db}
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- ulflow-network
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '1'
|
||||||
|
memory: 1G
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-user} -d ${DB_NAME:-ulflow_db}"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
# Nginx Reverse Proxy
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: ulflow-nginx
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/conf.d:/etc/nginx/conf.d
|
||||||
|
- ./nginx/ssl:/etc/nginx/ssl
|
||||||
|
- ./nginx/logs:/var/log/nginx
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
networks:
|
||||||
|
- ulflow-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ulflow-network:
|
||||||
|
driver: bridge
|
||||||
85
docker-compose.yml
Normal file
85
docker-compose.yml
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# API Service
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.local
|
||||||
|
container_name: ulflow-api
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- go-modules:/go/pkg/mod
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
networks:
|
||||||
|
- ulflow-network
|
||||||
|
|
||||||
|
# PostgreSQL Database
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: ulflow-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${DB_USER:-user}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
|
||||||
|
POSTGRES_DB: ${DB_NAME:-ulflow_db}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- ulflow-network
|
||||||
|
|
||||||
|
# Gitea (Git Server, CI/CD, Registry)
|
||||||
|
gitea:
|
||||||
|
image: gitea/gitea:1.21
|
||||||
|
container_name: ulflow-gitea
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- USER_UID=1000
|
||||||
|
- USER_GID=1000
|
||||||
|
- GITEA__database__DB_TYPE=postgres
|
||||||
|
- GITEA__database__HOST=postgres:5432
|
||||||
|
- GITEA__database__NAME=gitea
|
||||||
|
- GITEA__database__USER=${DB_USER:-user}
|
||||||
|
- GITEA__database__PASSWD=${DB_PASSWORD:-password}
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
- "2222:22"
|
||||||
|
volumes:
|
||||||
|
- gitea-data:/data
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
networks:
|
||||||
|
- ulflow-network
|
||||||
|
|
||||||
|
# Gitea Runner for CI/CD
|
||||||
|
gitea-runner:
|
||||||
|
image: gitea/act_runner:latest
|
||||||
|
container_name: ulflow-gitea-runner
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- gitea-runner-data:/data
|
||||||
|
depends_on:
|
||||||
|
- gitea
|
||||||
|
networks:
|
||||||
|
- ulflow-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
gitea-data:
|
||||||
|
gitea-runner-data:
|
||||||
|
go-modules:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ulflow-network:
|
||||||
|
driver: bridge
|
||||||
1
docs/.obsidian/app.json
vendored
Normal file
1
docs/.obsidian/app.json
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
1
docs/.obsidian/appearance.json
vendored
Normal file
1
docs/.obsidian/appearance.json
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
31
docs/.obsidian/core-plugins.json
vendored
Normal file
31
docs/.obsidian/core-plugins.json
vendored
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"file-explorer": true,
|
||||||
|
"global-search": true,
|
||||||
|
"switcher": true,
|
||||||
|
"graph": true,
|
||||||
|
"backlink": true,
|
||||||
|
"canvas": true,
|
||||||
|
"outgoing-link": true,
|
||||||
|
"tag-pane": true,
|
||||||
|
"properties": false,
|
||||||
|
"page-preview": true,
|
||||||
|
"daily-notes": true,
|
||||||
|
"templates": true,
|
||||||
|
"note-composer": true,
|
||||||
|
"command-palette": true,
|
||||||
|
"slash-command": false,
|
||||||
|
"editor-status": true,
|
||||||
|
"bookmarks": true,
|
||||||
|
"markdown-importer": false,
|
||||||
|
"zk-prefixer": false,
|
||||||
|
"random-note": false,
|
||||||
|
"outline": true,
|
||||||
|
"word-count": true,
|
||||||
|
"slides": false,
|
||||||
|
"audio-recorder": false,
|
||||||
|
"workspaces": false,
|
||||||
|
"file-recovery": true,
|
||||||
|
"publish": false,
|
||||||
|
"sync": true,
|
||||||
|
"webviewer": false
|
||||||
|
}
|
||||||
184
docs/.obsidian/workspace.json
vendored
Normal file
184
docs/.obsidian/workspace.json
vendored
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
{
|
||||||
|
"main": {
|
||||||
|
"id": "261ce7313e6dd2d1",
|
||||||
|
"type": "split",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "8cfa998b40f0304e",
|
||||||
|
"type": "tabs",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "6d1019fb7e553425",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "markdown",
|
||||||
|
"state": {
|
||||||
|
"file": "adapter.md",
|
||||||
|
"mode": "source",
|
||||||
|
"source": false
|
||||||
|
},
|
||||||
|
"icon": "lucide-file",
|
||||||
|
"title": "adapter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"direction": "vertical"
|
||||||
|
},
|
||||||
|
"left": {
|
||||||
|
"id": "f87c78d3a5a321a8",
|
||||||
|
"type": "split",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "015ce6e570ac0c70",
|
||||||
|
"type": "tabs",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "6172be51a41d7dc9",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "file-explorer",
|
||||||
|
"state": {
|
||||||
|
"sortOrder": "alphabetical",
|
||||||
|
"autoReveal": false
|
||||||
|
},
|
||||||
|
"icon": "lucide-folder-closed",
|
||||||
|
"title": "Trình duyệt tệp"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0564e6c5e875c5e5",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "search",
|
||||||
|
"state": {
|
||||||
|
"query": "",
|
||||||
|
"matchingCase": false,
|
||||||
|
"explainSearch": false,
|
||||||
|
"collapseAll": false,
|
||||||
|
"extraContext": false,
|
||||||
|
"sortOrder": "alphabetical"
|
||||||
|
},
|
||||||
|
"icon": "lucide-search",
|
||||||
|
"title": "Tìm kiếm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d2af93c860305d9b",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "bookmarks",
|
||||||
|
"state": {},
|
||||||
|
"icon": "lucide-bookmark",
|
||||||
|
"title": "Đánh dấu"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"direction": "horizontal",
|
||||||
|
"width": 300
|
||||||
|
},
|
||||||
|
"right": {
|
||||||
|
"id": "f08c8c30bdb753d9",
|
||||||
|
"type": "split",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "a243e45a9db5439d",
|
||||||
|
"type": "tabs",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "3edc5198b4139609",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "backlink",
|
||||||
|
"state": {
|
||||||
|
"file": "general.md",
|
||||||
|
"collapseAll": false,
|
||||||
|
"extraContext": false,
|
||||||
|
"sortOrder": "alphabetical",
|
||||||
|
"showSearch": false,
|
||||||
|
"searchQuery": "",
|
||||||
|
"backlinkCollapsed": false,
|
||||||
|
"unlinkedCollapsed": true
|
||||||
|
},
|
||||||
|
"icon": "links-coming-in",
|
||||||
|
"title": "Liên kết đến của general"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bd56178c8119e62e",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "outgoing-link",
|
||||||
|
"state": {
|
||||||
|
"file": "general.md",
|
||||||
|
"linksCollapsed": false,
|
||||||
|
"unlinkedCollapsed": true
|
||||||
|
},
|
||||||
|
"icon": "links-going-out",
|
||||||
|
"title": "Liên kết đi ra từ general"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bc29a86f17a39689",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "tag",
|
||||||
|
"state": {
|
||||||
|
"sortOrder": "frequency",
|
||||||
|
"useHierarchy": true,
|
||||||
|
"showSearch": false,
|
||||||
|
"searchQuery": ""
|
||||||
|
},
|
||||||
|
"icon": "lucide-tags",
|
||||||
|
"title": "Tags"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "8b998447c8dac66b",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "outline",
|
||||||
|
"state": {
|
||||||
|
"file": "general.md",
|
||||||
|
"followCursor": false,
|
||||||
|
"showSearch": false,
|
||||||
|
"searchQuery": ""
|
||||||
|
},
|
||||||
|
"icon": "lucide-list",
|
||||||
|
"title": "Đề cương của general"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"direction": "horizontal",
|
||||||
|
"width": 300,
|
||||||
|
"collapsed": true
|
||||||
|
},
|
||||||
|
"left-ribbon": {
|
||||||
|
"hiddenItems": {
|
||||||
|
"switcher:Mở bộ chuyển đổi nhanh": false,
|
||||||
|
"graph:Mở xem biểu đồ": false,
|
||||||
|
"canvas:Tạo bảng mới": false,
|
||||||
|
"daily-notes:Mở ghi chú hôm nay": false,
|
||||||
|
"templates:Chèn mẫu": false,
|
||||||
|
"command-palette:Mở khay lệnh": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active": "6d1019fb7e553425",
|
||||||
|
"lastOpenFiles": [
|
||||||
|
"workflow.md",
|
||||||
|
"ux.md",
|
||||||
|
"testing.md",
|
||||||
|
"spec.md",
|
||||||
|
"secrets.md",
|
||||||
|
"roadmap.md",
|
||||||
|
"general.md",
|
||||||
|
"adapter.md",
|
||||||
|
"architecture.md",
|
||||||
|
"changelog.md"
|
||||||
|
]
|
||||||
|
}
|
||||||
209
docs/AUTHENTICATION.md
Normal file
209
docs/AUTHENTICATION.md
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
# Hệ thống Xác thực và Phân quyền
|
||||||
|
|
||||||
|
## Mục lục
|
||||||
|
1. [Tổng quan](#tổng-quan)
|
||||||
|
2. [Luồng xử lý](#luồng-xử-lý)
|
||||||
|
- [Đăng nhập](#đăng-nhập)
|
||||||
|
- [Làm mới token](#làm-mới-token)
|
||||||
|
- [Đăng xuất](#đăng-xuất)
|
||||||
|
3. [Các thành phần chính](#các-thành-phần-chính)
|
||||||
|
- [Auth Middleware](#auth-middleware)
|
||||||
|
- [Token Service](#token-service)
|
||||||
|
- [Session Management](#session-management)
|
||||||
|
4. [Bảo mật](#bảo-mật)
|
||||||
|
5. [Tích hợp](#tích-hợp)
|
||||||
|
|
||||||
|
## Tổng quan
|
||||||
|
|
||||||
|
Hệ thống xác thực sử dụng JWT (JSON Web Tokens) với cơ chế refresh token để đảm bảo bảo mật. Mỗi phiên đăng nhập sẽ có:
|
||||||
|
- Access Token: Có thời hạn ngắn (15-30 phút)
|
||||||
|
- Refresh Token: Có thời hạn dài hơn (7-30 ngày)
|
||||||
|
- Session ID: Định danh duy nhất cho mỗi phiên
|
||||||
|
|
||||||
|
## Luồng xử lý
|
||||||
|
|
||||||
|
### Đăng nhập
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client
|
||||||
|
participant AuthController
|
||||||
|
participant AuthService
|
||||||
|
participant TokenService
|
||||||
|
participant SessionStore
|
||||||
|
|
||||||
|
Client->>AuthController: POST /api/v1/auth/login
|
||||||
|
AuthController->>AuthService: Authenticate(credentials)
|
||||||
|
AuthService->>UserRepository: FindByEmail(email)
|
||||||
|
AuthService->>PasswordUtil: CompareHashAndPassword()
|
||||||
|
AuthService->>TokenService: GenerateTokens(userID, sessionID)
|
||||||
|
TokenService-->>AuthService: tokens
|
||||||
|
AuthService->>SessionStore: Create(session)
|
||||||
|
AuthService-->>AuthController: authResponse
|
||||||
|
AuthController-->>Client: {accessToken, refreshToken, user}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Làm mới Token
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client
|
||||||
|
participant AuthController
|
||||||
|
participant TokenService
|
||||||
|
participant SessionStore
|
||||||
|
|
||||||
|
Client->>AuthController: POST /api/v1/auth/refresh
|
||||||
|
AuthController->>TokenService: RefreshToken(refreshToken)
|
||||||
|
TokenService->>SessionStore: Get(sessionID)
|
||||||
|
SessionStore-->>TokenService: session
|
||||||
|
TokenService->>TokenService: ValidateRefreshToken()
|
||||||
|
TokenService->>SessionStore: UpdateLastUsed()
|
||||||
|
TokenService-->>AuthController: newTokens
|
||||||
|
AuthController-->>Client: {accessToken, refreshToken}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Đăng xuất
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client
|
||||||
|
participant AuthController
|
||||||
|
participant TokenService
|
||||||
|
participant SessionStore
|
||||||
|
|
||||||
|
Client->>AuthController: POST /api/v1/auth/logout
|
||||||
|
AuthController->>TokenService: ExtractTokenMetadata()
|
||||||
|
TokenService-->>AuthController: tokenClaims
|
||||||
|
AuthController->>SessionStore: Delete(sessionID)
|
||||||
|
AuthController-->>Client: 200 OK
|
||||||
|
```
|
||||||
|
|
||||||
|
## Các thành phần chính
|
||||||
|
|
||||||
|
### Auth Middleware
|
||||||
|
|
||||||
|
#### `Authenticate()`
|
||||||
|
- **Mục đích**: Xác thực access token trong header Authorization
|
||||||
|
- **Luồng xử lý**:
|
||||||
|
1. Lấy token từ header
|
||||||
|
2. Xác thực token
|
||||||
|
3. Kiểm tra session trong store
|
||||||
|
4. Lưu thông tin user vào context
|
||||||
|
|
||||||
|
#### `RequireRole(roles ...string)`
|
||||||
|
- **Mục đích**: Kiểm tra quyền truy cập dựa trên vai trò
|
||||||
|
- **Luồng xử lý**:
|
||||||
|
1. Lấy thông tin user từ context
|
||||||
|
2. Kiểm tra user có vai trò phù hợp không
|
||||||
|
3. Trả về lỗi nếu không có quyền
|
||||||
|
|
||||||
|
### Token Service
|
||||||
|
|
||||||
|
#### `GenerateTokens(userID, sessionID)`
|
||||||
|
- Tạo access token và refresh token
|
||||||
|
- Lưu thông tin session
|
||||||
|
- Trả về cặp token
|
||||||
|
|
||||||
|
#### `ValidateToken(token)`
|
||||||
|
- Xác thực chữ ký token
|
||||||
|
- Kiểm tra thời hạn
|
||||||
|
- Trả về claims nếu hợp lệ
|
||||||
|
|
||||||
|
### Session Management
|
||||||
|
|
||||||
|
#### `CreateSession(session)`
|
||||||
|
- Tạo session mới
|
||||||
|
- Lưu vào Redis với TTL
|
||||||
|
- Trả về session ID
|
||||||
|
|
||||||
|
#### `GetSession(sessionID)`
|
||||||
|
- Lấy thông tin session từ Redis
|
||||||
|
- Cập nhật thời gian truy cập cuối
|
||||||
|
- Trả về session nếu tồn tại
|
||||||
|
|
||||||
|
## Bảo mật
|
||||||
|
|
||||||
|
1. **Token Storage**
|
||||||
|
- Access Token: Lưu trong memory (không lưu localStorage)
|
||||||
|
- Refresh Token: HttpOnly, Secure, SameSite=Strict cookie
|
||||||
|
|
||||||
|
2. **Token Rotation**
|
||||||
|
- Mỗi lần refresh sẽ tạo cặp token mới
|
||||||
|
- Vô hiệu hóa refresh token cũ
|
||||||
|
|
||||||
|
3. **Thu hồi token**
|
||||||
|
- Đăng xuất sẽ xóa session
|
||||||
|
- Có thể thu hồi tất cả session của user
|
||||||
|
|
||||||
|
## Tích hợp
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
1. **Xử lý token**
|
||||||
|
```javascript
|
||||||
|
// Lưu token vào memory
|
||||||
|
let accessToken = null;
|
||||||
|
|
||||||
|
// Hàm gọi API với token
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: '/api/v1',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Thêm interceptor để gắn token
|
||||||
|
api.interceptors.request.use(config => {
|
||||||
|
if (accessToken) {
|
||||||
|
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Xử lý lỗi 401
|
||||||
|
export function setupResponseInterceptor(logout) {
|
||||||
|
api.interceptors.response.use(
|
||||||
|
response => response,
|
||||||
|
async error => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
|
||||||
|
if (error.response.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
try {
|
||||||
|
const { accessToken: newToken } = await refreshToken();
|
||||||
|
accessToken = newToken;
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||||
|
return api(originalRequest);
|
||||||
|
} catch (error) {
|
||||||
|
logout();
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
1. **Cấu hình**
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
access_token_expiry: 15m
|
||||||
|
refresh_token_expiry: 7d
|
||||||
|
jwt_secret: your-secret-key
|
||||||
|
refresh_secret: your-refresh-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Sử dụng middleware**
|
||||||
|
```go
|
||||||
|
// Áp dụng auth middleware
|
||||||
|
router.Use(authMiddleware.Authenticate())
|
||||||
|
|
||||||
|
// Route yêu cầu đăng nhập
|
||||||
|
router.GET("/profile", userHandler.GetProfile)
|
||||||
|
|
||||||
|
// Route yêu cầu quyền admin
|
||||||
|
router.GET("/admin", authMiddleware.RequireRole("admin"), adminHandler.Dashboard)
|
||||||
|
```
|
||||||
149
docs/adapter.md
Normal file
149
docs/adapter.md
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
# Adapter Layer
|
||||||
|
|
||||||
|
## Tổng quan
|
||||||
|
Adapter là lớp giao tiếp giữa ứng dụng và các hệ thống bên ngoài. Layer này cung cấp khả năng tích hợp với bên thứ ba và sự độc lập của core business logic.
|
||||||
|
|
||||||
|
## Nguyên tắc thiết kế
|
||||||
|
- Tách biệt domain logic với chi tiết triển khai bên ngoài
|
||||||
|
- Dễ dàng thay thế các implementation mà không ảnh hưởng tới business logic
|
||||||
|
- Dependency inversion - core logic không phụ thuộc vào chi tiết triển khai
|
||||||
|
- Interface-driven design cho các external services
|
||||||
|
|
||||||
|
## Cấu trúc thư mục
|
||||||
|
```
|
||||||
|
internal/adapter/
|
||||||
|
│── persistence/ # Database adapters
|
||||||
|
│ │── postgres/ # PostgreSQL implementation
|
||||||
|
│ │── mysql/ # MySQL implementation (optional)
|
||||||
|
│ └── sqlite/ # SQLite implementation (optional)
|
||||||
|
│── messaging/ # Messaging system adapters
|
||||||
|
│── storage/ # File storage adapters
|
||||||
|
│── externalapi/ # Third-party service adapters
|
||||||
|
│── notification/ # Notification service adapters
|
||||||
|
└── cache/ # Cache adapters
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Adapter
|
||||||
|
|
||||||
|
Adapter database được triển khai với tính năng:
|
||||||
|
|
||||||
|
- Kết nối tự động với PostgreSQL
|
||||||
|
- Cấu hình connection pool
|
||||||
|
- Xử lý migration tự động
|
||||||
|
- Triển khai repository pattern
|
||||||
|
|
||||||
|
### Cấu hình
|
||||||
|
|
||||||
|
Cấu hình database được định nghĩa trong file `configs/config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
database:
|
||||||
|
driver: "postgres"
|
||||||
|
host: "localhost"
|
||||||
|
port: 5432
|
||||||
|
username: "postgres"
|
||||||
|
password: "postgres"
|
||||||
|
database: "ulflow"
|
||||||
|
ssl_mode: "disable"
|
||||||
|
max_open_conns: 25
|
||||||
|
max_idle_conns: 5
|
||||||
|
conn_max_lifetime: 300
|
||||||
|
migration_path: "migrations"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Triển khai adapter
|
||||||
|
|
||||||
|
### 1. Định nghĩa interface
|
||||||
|
```go
|
||||||
|
// pkg/adapter/payment/interface.go
|
||||||
|
package payment
|
||||||
|
|
||||||
|
type PaymentProvider interface {
|
||||||
|
ProcessPayment(amount float64, currency string, metadata map[string]string) (string, error)
|
||||||
|
RefundPayment(paymentId string, amount float64) error
|
||||||
|
GetPaymentStatus(paymentId string) (string, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Triển khai cụ thể
|
||||||
|
```go
|
||||||
|
// pkg/adapter/payment/stripe/stripe.go
|
||||||
|
package stripe
|
||||||
|
|
||||||
|
import "github.com/your-project/pkg/adapter/payment"
|
||||||
|
|
||||||
|
type StripeAdapter struct {
|
||||||
|
apiKey string
|
||||||
|
client *stripe.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStripeAdapter(apiKey string) *StripeAdapter {
|
||||||
|
return &StripeAdapter{
|
||||||
|
apiKey: apiKey,
|
||||||
|
client: stripe.NewClient(apiKey),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StripeAdapter) ProcessPayment(amount float64, currency string, metadata map[string]string) (string, error) {
|
||||||
|
// Triển khai với Stripe API
|
||||||
|
}
|
||||||
|
|
||||||
|
// Triển khai các phương thức khác...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Sử dụng mock để test
|
||||||
|
```go
|
||||||
|
// pkg/adapter/payment/mock/mock.go
|
||||||
|
package mock
|
||||||
|
|
||||||
|
import "github.com/your-project/pkg/adapter/payment"
|
||||||
|
|
||||||
|
type MockPaymentAdapter struct {
|
||||||
|
// Fields to store test data
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockPaymentAdapter) ProcessPayment(amount float64, currency string, metadata map[string]string) (string, error) {
|
||||||
|
// Mock implementation for testing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Triển khai các phương thức khác...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependency Injection
|
||||||
|
|
||||||
|
Sử dụng dependency injection để đưa adapter vào business logic:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/transaction/order/service.go
|
||||||
|
package order
|
||||||
|
|
||||||
|
type OrderService struct {
|
||||||
|
paymentProvider payment.PaymentProvider
|
||||||
|
// Other dependencies...
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOrderService(paymentProvider payment.PaymentProvider) *OrderService {
|
||||||
|
return &OrderService{
|
||||||
|
paymentProvider: paymentProvider,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OrderService) PlaceOrder(order *resource.Order) error {
|
||||||
|
// Sử dụng payment provider thông qua interface
|
||||||
|
paymentId, err := s.paymentProvider.ProcessPayment(
|
||||||
|
order.TotalAmount,
|
||||||
|
order.Currency,
|
||||||
|
map[string]string{"orderId": order.ID},
|
||||||
|
)
|
||||||
|
// Xử lý kết quả...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Mỗi adapter chỉ giao tiếp với một dịch vụ bên ngoài**
|
||||||
|
2. **Xử lý lỗi phù hợp và mapping sang domain errors**
|
||||||
|
3. **Cung cấp logging và metrics cho mỗi adapter**
|
||||||
|
4. **Retry mechanisms cho các dịch vụ không ổn định**
|
||||||
|
5. **Circuit breaking để tránh cascading failures**
|
||||||
|
6. **Feature toggles để dễ dàng chuyển đổi giữa các provider**
|
||||||
234
docs/architecture.md
Normal file
234
docs/architecture.md
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
# Kiến trúc hệ thống
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
- Đây là dự án tạo ra Starter Kit Golang Backend cho Team ULFlow để có thể khởi tạo các dự án trong thời gian ngắn, nhanh chóng mà vẫn đảm bảo các yếu tố cơ bản
|
||||||
|
- Sử dụng Mô hình DDD (Domain Driven Development) - Customize
|
||||||
|
- `Resource`: Các Aggregate DDD
|
||||||
|
- `Transaction`: Các Saga điều phối luồng nghiệp vụ phức tạp
|
||||||
|
- `Adapter`: Xử lý giao tiếp với các hệ thống bên ngoài
|
||||||
|
- `Helper`: Các thư viện, tiện ích dùng chung
|
||||||
|
- `Transport`: Lớp giao diện người dùng
|
||||||
|
- Thành phần kiến trúc chi tiết (U-Hierarchy)
|
||||||
|
- `ubit`: Đơn vị logic nhỏ nhất (hàm, type, hằng số)
|
||||||
|
- `ubrick`: Tập hợp các `ubit` liên quan
|
||||||
|
- `ublock`: Thành phần hoạt động độc lập tương đối
|
||||||
|
- `ubundle`: Tính năng hoàn chỉnh cho người dùng
|
||||||
|
|
||||||
|
## Nguyên tắc thiết kế
|
||||||
|
- **User-Centric**: Ưu tiên trải nghiệm người dùng, giảm thiểu sự phức tạp
|
||||||
|
- **Data-Oriented Programming (DOP)**: Thiết kế xoay quanh luồng dữ liệu và các biến đổi dữ liệu
|
||||||
|
- **Domain-Driven Design (DDD)**: Áp dụng các khái niệm cốt lõi (Bounded Context, Aggregates, Domain Events)
|
||||||
|
|
||||||
|
## Áp dụng DDD trong Dự án
|
||||||
|
|
||||||
|
### Resource
|
||||||
|
- Tuân theo nguyên tắc Aggregate trong DDD
|
||||||
|
- Mỗi Resource đại diện cho một thực thể nghiệp vụ
|
||||||
|
- Bao gồm các thuộc tính và hành vi liên quan
|
||||||
|
- Đảm bảo tính nhất quán và logic nghiệp vụ
|
||||||
|
|
||||||
|
### Transaction
|
||||||
|
- Triển khai mẫu Saga để điều phối các hoạt động phức tạp
|
||||||
|
- Đảm bảo tính toàn vẹn dữ liệu xuyên suốt các Resource
|
||||||
|
- Xử lý các trường hợp lỗi và rollback khi cần thiết
|
||||||
|
|
||||||
|
### Adapter
|
||||||
|
- Cung cấp giao diện giao tiếp với hệ thống bên ngoài
|
||||||
|
- Triển khai mẫu Adapter để đảm bảo tính linh hoạt
|
||||||
|
- Dễ dàng thay thế các thành phần khi cần thiết
|
||||||
|
|
||||||
|
### Helper
|
||||||
|
- Cung cấp các tiện ích dùng chung
|
||||||
|
- Triển khai các công cụ hỗ trợ phát triển
|
||||||
|
- Tối ưu hóa mã lệnh và tăng khả năng tái sử dụng
|
||||||
|
|
||||||
|
### UIUX
|
||||||
|
- Lớp giao diện người dùng (API, Web Interface)
|
||||||
|
- Triển khai theo nguyên tắc thiết kế hướng người dùng
|
||||||
|
- Tách biệt với logic nghiệp vụ
|
||||||
|
|
||||||
|
## U-Hierarchy
|
||||||
|
|
||||||
|
Hệ thống phân cấp U là một cách tiếp cận duy nhất cho tổ chức mã:
|
||||||
|
|
||||||
|
### ubit
|
||||||
|
- Đơn vị nhỏ nhất của mã
|
||||||
|
- Ví dụ: một hàm, một constant, một type
|
||||||
|
- Mục đích đơn lẻ, dễ test
|
||||||
|
|
||||||
|
### ubrick
|
||||||
|
- Tập hợp các ubit liên quan đến nhau
|
||||||
|
- Cung cấp một chức năng cụ thể
|
||||||
|
- Ví dụ: một package nhỏ, một nhóm hàm liên quan
|
||||||
|
|
||||||
|
### ublock
|
||||||
|
- Thành phần có thể hoạt động độc lập
|
||||||
|
- Bao gồm nhiều ubrick làm việc cùng nhau
|
||||||
|
- Ví dụ: một module hoặc service
|
||||||
|
|
||||||
|
### ubundle
|
||||||
|
- Một tính năng hoàn chỉnh cho người dùng
|
||||||
|
- Kết hợp nhiều ublock để tạo ra trải nghiệm người dùng
|
||||||
|
- Ví dụ: một tính năng end-to-end
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
starter-kit/
|
||||||
|
├── .gitea/
|
||||||
|
│ ├── workflows/
|
||||||
|
│ │ ├── ci.yml
|
||||||
|
│ │ └── docker.yml
|
||||||
|
│ ├── .hooks/
|
||||||
|
│ │ ├──pre-commit
|
||||||
|
│ │ └──prepare-commit-msg
|
||||||
|
│ └── commit-template.txt
|
||||||
|
├── cmd/
|
||||||
|
│ └── app/ # Hoặc 'server/', 'api/' - Entrypoint chính của ứng dụng
|
||||||
|
│ └── main.go
|
||||||
|
├── internal/
|
||||||
|
│ ├── resource/ # Lớp chứa các Aggregates/Entities (DDD)
|
||||||
|
│ │ └── example/ # Một module ví dụ rất cơ bản
|
||||||
|
│ │ ├── example_aggregate.go
|
||||||
|
│ │ ├── example_types.go
|
||||||
|
│ │ └── example_repository_interface.go # Interface cho repository
|
||||||
|
│ └── transaction/ # Lớp chứa các Use Cases/Application Services/Sagas (DDD)
|
||||||
|
│ │ └── example/ # Một module ví dụ rất cơ bản
|
||||||
|
│ │ └── example_transaction.go
|
||||||
|
│ ├── adapter/ # Lớp giao tiếp với các hệ thống bên ngoài hoặc hạ tầng
|
||||||
|
│ │ ├── persistence/ # Hoặc 'repository/', 'storage/' - Triển khai repositories
|
||||||
|
│ │ │ └── postgres/ # Ví dụ với PostgreSQL
|
||||||
|
│ │ │ ├── connection.go # Thiết lập kết nối DB
|
||||||
|
│ │ │ ├── example_postgres_repository.go # Triển khai repository cho example_aggregate
|
||||||
|
│ │ │ └── models.go # (Tùy chọn) GORM models hoặc struct cho DB mapping
|
||||||
|
│ │ |
|
||||||
|
│ │ └── externalapi/ # Giao tiếp với các dịch vụ bên ngoài
|
||||||
|
│ │ └── # example_service_client.go
|
||||||
|
│ ├── helper/ # Các thư viện, tiện ích dùng chung, không có business logic
|
||||||
|
│ │ ├── config/ # Load cấu hình (ví dụ: Gin)
|
||||||
|
│ │ │ └── load.go
|
||||||
|
│ │ ├── logger/ # Thiết lập logger (ví dụ: Logrus)
|
||||||
|
│ │ │ └── log.go
|
||||||
|
│ │ ├── validation/ # Tiện ích validation chung
|
||||||
|
│ │ │ └── common.go
|
||||||
|
│ │ ├── security/ # Tiện ích bảo mật (JWT, hashing)
|
||||||
|
│ │ │ ├── jwt_helper.go
|
||||||
|
│ │ │ └── password_helper.go
|
||||||
|
│ │ └── # ... (ví dụ: datetime, string_utils)
|
||||||
|
│ └── transport/ # Lớp giao tiếp với thế giới bên ngoài (ví dụ: HTTP)
|
||||||
|
│ └── http/ # Cụ thể cho HTTP transport
|
||||||
|
│ ├── handler/ # HTTP request handlers
|
||||||
|
│ │ ├── example_handler.go
|
||||||
|
│ │ ├── health_handler.go # Endpoint kiểm tra sức khỏe ứng dụng
|
||||||
|
│ │ └── middleware/ # HTTP middlewares (auth, logging, cors, recovery)
|
||||||
|
│ │ ├── auth_middleware.go
|
||||||
|
│ │ ├── request_logger_middleware.go
|
||||||
|
│ │ └── error_handling_middleware.go
|
||||||
|
│ ├── router.go # Định nghĩa các routes (ví dụ: sử dụng Gin, Chi)
|
||||||
|
│ └── dto/ # Data Transfer Objects cho request/response
|
||||||
|
│ ├── example_dto.go
|
||||||
|
│ └── common_dto.go
|
||||||
|
├── docs/ # Tài liệu của starter-kit
|
||||||
|
├── templates/ # Chứa các file cấu hình mẫu
|
||||||
|
│ ├── config.example.yaml # Đổi tên để rõ là file mẫu
|
||||||
|
│ └── .env.example # Các biến môi trường mẫu
|
||||||
|
├── migrations/ # Chứa các file SQL migration
|
||||||
|
│ └── 000001_init_schema.example.sql # Migration mẫu
|
||||||
|
├── api/ # (Tùy chọn) Định nghĩa API (ví dụ: OpenAPI/Swagger specs)
|
||||||
|
│ └── openapi.yaml
|
||||||
|
├── scripts/ # Các shell script tiện ích (nếu Makefile không đủ)
|
||||||
|
│ ├── # setup_env.sh
|
||||||
|
│ └── # run_checks.sh
|
||||||
|
├── Makefile # Các lệnh tiện ích (build, test, lint, run, docker, ...)
|
||||||
|
├── go.mod # Tên module nên là tên của starter-kit,
|
||||||
|
├── .gitignore
|
||||||
|
└── README.md # Hướng dẫn nhanh và tổng quan về starter-kit
|
||||||
|
|
||||||
|
### Checklist cơ bản của vòng đời App
|
||||||
|
|
||||||
|
I. Thiết lập Cơ bản và Cấu trúc (Basic Setup & Structure)
|
||||||
|
|
||||||
|
[ ] Tách biệt Logic Khởi tạo:
|
||||||
|
Yêu cầu: main.go chỉ chứa hàm main() và các lệnh gọi ở mức cao nhất. Logic chi tiết cho việc khởi tạo từng thành phần (config, logger, server, DB) phải nằm trong các package/hàm riêng biệt (ví dụ: internal/common/config, internal/common/logger, internal/transport/http/server, internal/infrastructure/db).
|
||||||
|
Mục đích: Giữ main.go ngắn gọn, dễ đọc, dễ hiểu vai trò điều phối của nó.
|
||||||
|
[ ] Hàm main() rõ ràng:
|
||||||
|
Yêu cầu: Hàm main() nên thực hiện các bước khởi tạo một cách tuần tự, logic.
|
||||||
|
Mục đích: Dễ dàng theo dõi luồng khởi động của ứng dụng.
|
||||||
|
[ ] Xử lý lỗi khởi tạo nghiêm trọng:
|
||||||
|
Yêu cầu: Nếu một bước khởi tạo thiết yếu (ví dụ: load config, kết nối DB) thất bại, ứng dụng nên log lỗi rõ ràng và thoát (ví dụ: log.Fatalf hoặc logger.Fatal).
|
||||||
|
Mục đích: Tránh việc ứng dụng chạy trong trạng thái không ổn định hoặc không đầy đủ.
|
||||||
|
II. Quản lý Cấu hình (Configuration Management)
|
||||||
|
|
||||||
|
[ ] Load Cấu hình:
|
||||||
|
Yêu cầu: Gọi một hàm/package chuyên biệt để đọc cấu hình từ các nguồn (file YAML/JSON/.env, biến môi trường).
|
||||||
|
Ví dụ: cfg, err := config.Load("configs/", ".env.example")
|
||||||
|
Mục đích: Tập trung logic load config, dễ dàng thay đổi nguồn hoặc định dạng config.
|
||||||
|
[ ] Validate Cấu hình Cơ bản (Đề xuất):
|
||||||
|
Yêu cầu (Đề xuất): Sau khi load, có một bước kiểm tra sơ bộ các giá trị cấu hình thiết yếu (ví dụ: port server có hợp lệ, thông tin kết nối DB có đủ).
|
||||||
|
Mục đích: Phát hiện lỗi cấu hình sớm.
|
||||||
|
III. Logging
|
||||||
|
|
||||||
|
[ ] Structured Logging với Logrus:
|
||||||
|
- Sử dụng Logrus cho structured logging với JSON format
|
||||||
|
- Hỗ trợ các log level: debug, info, warn, error
|
||||||
|
- Tích hợp request ID tracking cho HTTP requests
|
||||||
|
- Cấu hình logging được quản lý tập trung trong section `logger`
|
||||||
|
|
||||||
|
[ ] Middleware Logging:
|
||||||
|
- Tự động log các HTTP request với thông tin chi tiết:
|
||||||
|
- Request ID
|
||||||
|
- Method
|
||||||
|
- Path
|
||||||
|
- Status code
|
||||||
|
- Latency
|
||||||
|
- Client IP
|
||||||
|
- User agent
|
||||||
|
|
||||||
|
[ ] Error Handling và Context:
|
||||||
|
- Tích hợp error context vào logs
|
||||||
|
- Structured fields cho phép phân tích và tìm kiếm hiệu quả
|
||||||
|
- Theo dõi chuỗi lỗi với error wrapping
|
||||||
|
IV. Khởi tạo Dependencies Cơ sở hạ tầng (Infrastructure Dependencies)
|
||||||
|
|
||||||
|
[ ] Khởi tạo Kết nối Database (nếu starter kit có sẵn):
|
||||||
|
Yêu cầu: Gọi một hàm/package để thiết lập kết nối tới DB (ví dụ: PostgreSQL) và quản lý connection pool.
|
||||||
|
Ví dụ: dbConn, err := database.New(cfg.DBConfig, appLogger)
|
||||||
|
Mục đích: Chuẩn bị sẵn sàng cho việc tương tác với DB.
|
||||||
|
[ ] Đóng Kết nối Database khi Shutdown:
|
||||||
|
Yêu cầu: Đảm bảo kết nối DB được đóng một cách an toàn khi ứng dụng tắt (sử dụng defer hoặc trong quá trình graceful shutdown).
|
||||||
|
Mục đích: Tránh rò rỉ tài nguyên.
|
||||||
|
V. Thiết lập Server (ví dụ: HTTP Server)
|
||||||
|
|
||||||
|
[ ] Khởi tạo Router (từ package riêng):
|
||||||
|
Yêu cầu: Logic định nghĩa routes và gắn handlers phải nằm trong một package riêng (ví dụ: internal/transport/http/router). main.go chỉ khởi tạo nó.
|
||||||
|
Ví dụ: httpRouter := router.New(appLogger /*, dbConn, otherServices */)
|
||||||
|
Mục đích: Tách biệt rõ ràng logic routing và business.
|
||||||
|
[ ] Khởi tạo HTTP Server:
|
||||||
|
Yêu cầu: Tạo instance http.Server với các cấu hình cơ bản (địa chỉ, port, handler là router đã khởi tạo, có thể cả timeouts cơ bản).
|
||||||
|
[ ] Đăng ký Middleware Phi tính năng Cơ bản (thường trong package router hoặc server setup):
|
||||||
|
Yêu cầu: Đảm bảo các middleware thiết yếu như request logging, panic recovery, CORS (nếu cần) được áp dụng.
|
||||||
|
Mục đích: Tăng cường độ tin cậy và khả năng giám sát cho API.
|
||||||
|
[ ] Đăng ký Health Check Endpoint (trong router):
|
||||||
|
Yêu cầu: Cung cấp một endpoint (/healthz, /status) để kiểm tra tình trạng hoạt động của ứng dụng.
|
||||||
|
Mục đích: Hỗ trợ giám sát và tự động hóa vận hành.
|
||||||
|
VI. Quản lý Vòng đời Ứng dụng (Application Lifecycle Management)
|
||||||
|
|
||||||
|
[ ] Chạy Server trong Goroutine riêng:
|
||||||
|
Yêu cầu: httpServer.ListenAndServe() (hoặc tương tự) được chạy trong một goroutine để không block hàm main().
|
||||||
|
[ ] Xử lý Graceful Shutdown:
|
||||||
|
Yêu cầu: Lắng nghe các tín hiệu OS (SIGINT, SIGTERM) để thực hiện shutdown một cách an toàn.
|
||||||
|
Bao gồm:
|
||||||
|
Tạo channel để nhận tín hiệu: quit := make(chan os.Signal, 1)
|
||||||
|
Đăng ký tín hiệu: signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
Block cho đến khi nhận tín hiệu: <-quit
|
||||||
|
Gọi httpServer.Shutdown(ctx) với một context có timeout.
|
||||||
|
Log các bước shutdown.
|
||||||
|
Mục đích: Đảm bảo ứng dụng hoàn thành các request đang xử lý, đóng kết nối an toàn, tránh mất dữ liệu.
|
||||||
|
|
||||||
|
[ ] Xử lý Error và Log:
|
||||||
|
Yêu cầu: Tất cả các error từ các thành phần (DB, HTTP, middleware) nên được log rõ ràng, bao gồm thông tin context (ví dụ: request ID, method, URL).
|
||||||
|
Mục đích: Hỗ trợ debug và giám sát vấn đề khi xảy ra.
|
||||||
|
VII. Quản lý tài nguyên (Resource Management)
|
||||||
|
|
||||||
|
[ ] Đóng tài nguyên khi không cần thiết:
|
||||||
|
Yêu cầu: Đảm bảo tất cả các tài nguyên (connection, file, mutex, channel) được đóng hoặc giải phóng khi không còn sử dụng.
|
||||||
|
Mục đích: Tránh rò rỉ tài nguyên và đảm bảo hiệu quả sử dụng bộ nhớ.
|
||||||
63
docs/changelog.md
Normal file
63
docs/changelog.md
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Tất cả những thay đổi đáng chú ý trong dự án sẽ được ghi lại ở đây.
|
||||||
|
|
||||||
|
Định dạng dựa trên [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
và dự án này tuân theo [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Logger Improvements**:
|
||||||
|
- Hỗ trợ phân biệt stdout/stderr cho các mức log khác nhau
|
||||||
|
- Tự động thêm thông tin người gọi (caller) vào log
|
||||||
|
- Hỗ trợ nhiều định dạng log (JSON, Text)
|
||||||
|
- Tự động thêm các trường mặc định vào mỗi log entry
|
||||||
|
- Tối ưu hiệu năng với buffer và sync.Pool
|
||||||
|
- Hỗ trợ log rotation thông qua các hooks
|
||||||
|
- Tài liệu chi tiết về cách sử dụng và cấu hình
|
||||||
|
- Thread-safe implementation
|
||||||
|
- Hỗ trợ context và request-scoped fields
|
||||||
|
- Tích hợp với cấu hình ứng dụng
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Logger Refactor**:
|
||||||
|
- Thay đổi cấu trúc package logger để dễ mở rộng
|
||||||
|
- Cải thiện hiệu suất với ít cấp phát bộ nhớ hơn
|
||||||
|
- Chuẩn hóa định dạng log đầu ra
|
||||||
|
- Cập nhật middleware HTTP để sử dụng logger mới
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Logger**:
|
||||||
|
- Sửa lỗi race condition khi khởi tạo logger
|
||||||
|
- Đảm bảo tất cả log đều có đầy đủ context
|
||||||
|
- Cải thiện xử lý lỗi khi cấu hình không hợp lệ
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Thay thế standard log package bằng Logrus trong toàn bộ ứng dụng
|
||||||
|
- Di chuyển cấu hình logging từ `app.log_level` sang section `logger` riêng biệt
|
||||||
|
- Cập nhật HTTP server để sử dụng structured logging
|
||||||
|
- Cải thiện validation cho database config với required_if conditions
|
||||||
|
- Nâng cấp cấu hình logger để hỗ trợ nhiều tùy chọn hơn
|
||||||
|
- Tối ưu hiệu năng của hệ thống logging
|
||||||
|
|
||||||
|
## [0.1.1] - 2025-05-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Triển khai module config với các chức năng:
|
||||||
|
- Đọc cấu hình từ file YAML
|
||||||
|
- Hỗ trợ biến môi trường
|
||||||
|
- Validation tự động các giá trị cấu hình
|
||||||
|
- Giá trị mặc định cho các tham số
|
||||||
|
- Thiết lập cấu trúc thư mục theo mô hình DDD (Domain-Driven Design)
|
||||||
|
- Cấu hình CI/CD với Gitea Workflows
|
||||||
|
- Cấu hình Docker cho môi trường development và production
|
||||||
|
- Tích hợp các Git hooks để đảm bảo chất lượng mã nguồn
|
||||||
|
|
||||||
|
## [0.1.0] - 2025-05-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Khởi tạo repository
|
||||||
|
- Tạo roadmap ban đầu
|
||||||
|
- Thiết lập kiến trúc hệ thống theo mô hình DDD
|
||||||
|
- Định nghĩa U-Hierarchy cho tổ chức mã nguồn
|
||||||
79
docs/general.md
Normal file
79
docs/general.md
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# Giới thiệu chung
|
||||||
|
|
||||||
|
## Tổng quan
|
||||||
|
- ULFlow Golang Starter Kit là một khung phát triển backend theo mô hình Domain-Driven Design (DDD)
|
||||||
|
- Được thiết kế để tạo ra các ứng dụng có thể bảo trì và mở rộng dễ dàng
|
||||||
|
- Mục tiêu: Cung cấp một cấu trúc dự án chuẩn, các thành phần cơ bản và công cụ phát triển hiệu quả
|
||||||
|
|
||||||
|
## Nguyên tắc thiết kế
|
||||||
|
|
||||||
|
- **User-Centric**: Ưu tiên trải nghiệm người dùng, giảm thiểu sự phức tạp
|
||||||
|
- **Data-Oriented Programming (DOP)**: Thiết kế xoay quanh luồng dữ liệu và các biến đổi dữ liệu
|
||||||
|
- **Domain-Driven Design (DDD)**: Áp dụng các khái niệm cốt lõi (Bounded Context, Aggregates, Domain Events)
|
||||||
|
|
||||||
|
## Tầm nhìn
|
||||||
|
- Tạo ra nền tảng phát triển linh hoạt, dễ mở rộng cho các dự án backend Golang
|
||||||
|
- Tích hợp các công nghệ hiện đại, best practices, và quy trình phát triển chuẩn
|
||||||
|
- Cung cấp môi trường phát triển thống nhất cho team
|
||||||
|
|
||||||
|
## Hướng dẫn cài đặt
|
||||||
|
|
||||||
|
### Yêu cầu hệ thống
|
||||||
|
- Go 1.23
|
||||||
|
- Docker và Docker Compose
|
||||||
|
- Git
|
||||||
|
|
||||||
|
### Cài đặt
|
||||||
|
1. Clone repository
|
||||||
|
```bash
|
||||||
|
git clone [repo-url] my-project
|
||||||
|
cd my-project
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Thiết lập môi trường
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Chỉnh sửa file .env theo nhu cầu
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Khởi động dev server
|
||||||
|
```bash
|
||||||
|
make dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cấu trúc thư mục
|
||||||
|
|
||||||
|
Dự án được tổ chức theo cấu trúc DDD chuẩn, với các thành phần chính:
|
||||||
|
|
||||||
|
```
|
||||||
|
│── .gitea/ # Gitea workflows & hooks
|
||||||
|
│── cmd/ # Điểm vào của ứng dụng
|
||||||
|
│ └── app/ # Main application
|
||||||
|
│── internal/ # Mã nguồn chính của ứng dụng
|
||||||
|
│ │── domain/ # Các đối tượng nghiệp vụ cốt lõi
|
||||||
|
│ │ │── resource/ # Resource (Aggregates/Entities)
|
||||||
|
│ │ └── transaction/ # Transaction (Use Cases)
|
||||||
|
│ │── adapter/ # Giao tiếp với bên ngoài
|
||||||
|
│ │ │── persistence/ # Repository implementations
|
||||||
|
│ │ └── externalapi/ # External API clients
|
||||||
|
│ │── helper/ # Tiện ích chung
|
||||||
|
│ │ │── config/ # Configuration
|
||||||
|
│ │ │── logger/ # Logging
|
||||||
|
│ │ │── security/ # Authentication/Authorization
|
||||||
|
│ │ └── validation/ # Input validation
|
||||||
|
│ └── transport/ # Giao tiếp với client
|
||||||
|
│ └── http/ # HTTP handlers & middleware
|
||||||
|
│── docs/ # Tài liệu
|
||||||
|
│── templates/ # Các file mẫu
|
||||||
|
│── configs/ # File cấu hình
|
||||||
|
│── migrations/ # Database migrations
|
||||||
|
│── api/ # API definitions (OpenAPI/Swagger)
|
||||||
|
│── scripts/ # Các tiện ích
|
||||||
|
│── test/ # End-to-end tests
|
||||||
|
└── tools/ # Development tools
|
||||||
|
|
||||||
|
## Hỗ trợ và đóng góp
|
||||||
|
|
||||||
|
- Báo lỗi và feature request: Tạo issue trên repository
|
||||||
|
- Đóng góp: Tạo pull request theo quy trình được mô tả trong workflow.md
|
||||||
|
- Liên hệ: [email/contact-info]
|
||||||
80
docs/review.md
Normal file
80
docs/review.md
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
🚀 Cải Thiện Luồng Xác Thực Cho Project Starter-Kit
|
||||||
|
Một Starter-kit chất lượng cần có hệ thống xác thực được xây dựng trên các nguyên tắc bảo mật và thực hành tốt nhất. Dưới đây là những cải thiện quan trọng:
|
||||||
|
|
||||||
|
1. Bảo Mật Refresh Token (RT) Phía Client – Ưu Tiên Hàng Đầu
|
||||||
|
Vấn đề cốt lõi: Lưu RT trong localStorage hoặc sessionStorage khiến chúng dễ bị tấn công XSS.
|
||||||
|
|
||||||
|
Giải pháp cho Starter-kit:
|
||||||
|
|
||||||
|
Sử dụng HttpOnly Cookies cho Refresh Token:
|
||||||
|
|
||||||
|
Bắt buộc: Starter-kit NÊN mặc định hoặc hướng dẫn rõ ràng việc sử dụng cookie HttpOnly để lưu RT. Điều này ngăn JavaScript phía client truy cập RT.
|
||||||
|
|
||||||
|
Access Token (AT) có thể được lưu trong bộ nhớ JavaScript (an toàn hơn localStorage cho AT có đời sống ngắn) hoặc sessionStorage nếu cần thiết cho SPA.
|
||||||
|
|
||||||
|
Thiết lập cờ Secure và SameSite cho Cookie:
|
||||||
|
|
||||||
|
Secure: Đảm bảo cookie chỉ được gửi qua HTTPS.
|
||||||
|
|
||||||
|
SameSite=Strict (hoặc SameSite=Lax): Giúp chống lại tấn công CSRF. Starter-kit NÊN có cấu hình này.
|
||||||
|
|
||||||
|
2. Quản Lý Refresh Token Phía Server – Đảm Bảo An Toàn
|
||||||
|
Thực hành tốt đã có: Refresh Token Rotation (xoay vòng RT khi sử dụng) là rất tốt.
|
||||||
|
|
||||||
|
Cải thiện cho Starter-kit:
|
||||||
|
|
||||||
|
Vô hiệu hóa RT cũ NGAY LẬP TỨC khi xoay vòng: Đảm bảo RT đã sử dụng không còn giá trị.
|
||||||
|
|
||||||
|
Thu hồi RT khi Logout: Endpoint /api/v1/auth/logout PHẢI xóa hoặc đánh dấu RT là đã thu hồi trong cơ sở dữ liệu. Chỉ xóa ở client là không đủ.
|
||||||
|
|
||||||
|
(Khuyến nghị cho Starter-kit nâng cao): Cân nhắc cơ chế phát hiện việc sử dụng RT đã bị đánh cắp (ví dụ: nếu một RT cũ được dùng lại sau khi đã xoay vòng, hãy thu hồi tất cả RT của user đó).
|
||||||
|
|
||||||
|
3. Tăng Cường Quy Trình Đăng Ký – Nền Tảng Người Dùng
|
||||||
|
Cải thiện cho Starter-kit:
|
||||||
|
|
||||||
|
Chính Sách Mật Khẩu Tối Thiểu:
|
||||||
|
|
||||||
|
Yêu cầu độ dài mật khẩu tối thiểu (ví dụ: 8 hoặc 10 ký tự). Starter-kit NÊN có điều này.
|
||||||
|
|
||||||
|
(Tùy chọn): Khuyến khích hoặc yêu cầu kết hợp chữ hoa, chữ thường, số, ký tự đặc biệt.
|
||||||
|
|
||||||
|
Xác Thực Email (Khuyến Nghị Mạnh Mẽ):
|
||||||
|
|
||||||
|
Starter-kit NÊN bao gồm module hoặc hướng dẫn tích hợp quy trình gửi email xác thực để kích hoạt tài khoản. Điều này giúp đảm bảo email hợp lệ và là kênh liên lạc quan trọng.
|
||||||
|
|
||||||
|
4. Bảo Vệ Chống Tấn Công Đăng Nhập – Lớp Phòng Thủ Cơ Bản
|
||||||
|
Cải thiện cho Starter-kit:
|
||||||
|
|
||||||
|
Rate Limiting cho Endpoint Đăng Nhập: Áp dụng giới hạn số lần thử đăng nhập thất bại (/api/v1/auth/login) dựa trên IP hoặc username/email.
|
||||||
|
|
||||||
|
Thông Báo Lỗi Chung Chung: Tránh các thông báo lỗi tiết lộ thông tin (ví dụ: "Username không tồn tại" hoặc "Sai mật khẩu"). Thay vào đó, sử dụng thông báo chung như "Tên đăng nhập hoặc mật khẩu không chính xác."
|
||||||
|
|
||||||
|
5. Thực Hành Tốt Nhất với JWT – Cốt Lõi Của Xác Thực
|
||||||
|
Cải thiện cho Starter-kit:
|
||||||
|
|
||||||
|
Quản Lý Secret Key An Toàn:
|
||||||
|
|
||||||
|
Hướng dẫn lưu trữ JWT secret key trong biến môi trường (environment variables).
|
||||||
|
|
||||||
|
Tuyệt đối KHÔNG hardcode secret key trong mã nguồn.
|
||||||
|
|
||||||
|
Sử Dụng Thuật Toán Ký Mạnh:
|
||||||
|
|
||||||
|
Mặc định sử dụng thuật toán đối xứng mạnh như HS256.
|
||||||
|
|
||||||
|
Khuyến nghị và cung cấp tùy chọn cho thuật toán bất đối xứng như RS256 (yêu cầu quản lý cặp public/private key) cho các hệ thống phức tạp hơn.
|
||||||
|
|
||||||
|
Giữ Payload của Access Token Nhỏ Gọn:
|
||||||
|
|
||||||
|
Chỉ chứa thông tin cần thiết nhất (ví dụ: userId, roles).
|
||||||
|
|
||||||
|
Cân nhắc thêm iss (issuer) và aud (audience) để tăng cường xác minh token.
|
||||||
|
|
||||||
|
6. Xử Lý Lỗi và Ghi Log (Logging) An Toàn
|
||||||
|
Cải thiện cho Starter-kit:
|
||||||
|
|
||||||
|
Không Ghi Log Thông Tin Nhạy Cảm: Tuyệt đối KHÔNG ghi log Access Token, Refresh Token, hoặc mật khẩu dưới bất kỳ hình thức nào.
|
||||||
|
|
||||||
|
Ghi Log Sự Kiện An Ninh: Hướng dẫn hoặc cung cấp cơ chế ghi log các sự kiện quan trọng (đăng nhập thành công/thất bại, yêu cầu làm mới token, thay đổi mật khẩu) một cách an toàn, không kèm dữ liệu nhạy cảm, để phục vụ việc giám sát và điều tra.
|
||||||
|
|
||||||
|
Bằng cách tích hợp những cải tiến này, Starter-kit của bạn sẽ cung cấp một điểm khởi đầu vững chắc và an toàn hơn cho các nhà phát triển.
|
||||||
98
docs/roadmap.md
Normal file
98
docs/roadmap.md
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
# Roadmap phát triển
|
||||||
|
|
||||||
|
## Roadmap cơ bản
|
||||||
|
- [x] Read Config from env file
|
||||||
|
- [x] HTTP Server with gin framework
|
||||||
|
- [x] JWT Authentication
|
||||||
|
- [x] Đăng ký người dùng
|
||||||
|
- [x] Đăng nhập với JWT
|
||||||
|
- [x] Refresh token
|
||||||
|
- [x] Xác thực token với middleware
|
||||||
|
- [x] Phân quyền cơ bản
|
||||||
|
- [x] Database with GORM + Postgres
|
||||||
|
- [ ] Health Check
|
||||||
|
- [ ] Unit Test with testify (Template)
|
||||||
|
- [ ] CI/CD with Gitea for Dev Team
|
||||||
|
- [ ] Build and Deploy with Docker + Docker Compose on Local
|
||||||
|
|
||||||
|
## Giai đoạn 1: Cơ sở hạ tầng cơ bản
|
||||||
|
- [x] Thiết lập cấu trúc dự án theo mô hình DDD
|
||||||
|
- [x] Cấu hình cơ bản: env, logging, error handling
|
||||||
|
- [x] Cấu hình Docker và Docker Compose
|
||||||
|
- [x] HTTP server với Gin
|
||||||
|
- [x] Database setup với GORM và Postgres
|
||||||
|
- [ ] Health check API endpoints
|
||||||
|
- Timeline: Q2/2025
|
||||||
|
|
||||||
|
## Giai đoạn 2: Bảo mật và xác thực (Q2/2025)
|
||||||
|
|
||||||
|
### 1. Xác thực và Ủy quyền
|
||||||
|
- [x] **JWT Authentication**
|
||||||
|
- [x] Đăng ký/Đăng nhập cơ bản
|
||||||
|
- [x] Refresh token
|
||||||
|
- [x] Xác thực token với middleware
|
||||||
|
- [x] Xử lý hết hạn token
|
||||||
|
|
||||||
|
|
||||||
|
- [x] **Phân quyền cơ bản**
|
||||||
|
- [x] Phân quyền theo role
|
||||||
|
- [ ] Quản lý role và permission
|
||||||
|
- [ ] Phân quyền chi tiết đến từng endpoint
|
||||||
|
- [ ] API quản lý người dùng và phân quyền
|
||||||
|
|
||||||
|
### 2. Bảo mật Ứng dụng
|
||||||
|
- [ ] **API Security**
|
||||||
|
- [ ] API rate limiting (throttling)
|
||||||
|
- [ ] Request validation và sanitization
|
||||||
|
- [ ] Chống tấn công DDoS cơ bản
|
||||||
|
- [ ] API versioning
|
||||||
|
|
||||||
|
- [ ] **Security Headers**
|
||||||
|
- [x] CORS configuration
|
||||||
|
- [ ] Security headers (CSP, HSTS, X-Content-Type, X-Frame-Options)
|
||||||
|
- [ ] Content Security Policy (CSP) tùy chỉnh
|
||||||
|
- [ ] XSS protection
|
||||||
|
|
||||||
|
### 3. Theo dõi và Giám sát
|
||||||
|
- [ ] **Audit Logging**
|
||||||
|
- [ ] Ghi log các hoạt động quan trọng
|
||||||
|
- [ ] Theo dõi đăng nhập thất bại
|
||||||
|
- [ ] Cảnh báo bảo mật
|
||||||
|
|
||||||
|
- [ ] **Monitoring**
|
||||||
|
- [ ] Tích hợp Prometheus
|
||||||
|
- [ ] Dashboard giám sát
|
||||||
|
- [ ] Cảnh báo bất thường
|
||||||
|
|
||||||
|
### 4. Cải thiện Hiệu suất
|
||||||
|
- [ ] **Tối ưu hóa**
|
||||||
|
- [ ] Redis cho caching
|
||||||
|
- [ ] Tối ưu truy vấn database
|
||||||
|
- [ ] Compression response
|
||||||
|
|
||||||
|
### Timeline
|
||||||
|
- Tuần 1-2: Hoàn thiện xác thực & phân quyền
|
||||||
|
- Tuần 3-4: Triển khai bảo mật API và headers
|
||||||
|
- Tuần 5-6: Hoàn thiện audit logging và monitoring
|
||||||
|
- Tuần 7-8: Tối ưu hiệu suất và kiểm thử bảo mật
|
||||||
|
|
||||||
|
## Giai đoạn 3: Tự động hóa
|
||||||
|
- [ ] Unit Test templates và mocks
|
||||||
|
- [ ] CI/CD với Gitea
|
||||||
|
- [ ] Automated deployment
|
||||||
|
- [ ] Linting và code quality checks
|
||||||
|
- Timeline: Q3/2025
|
||||||
|
|
||||||
|
## Giai đoạn 4: Mở rộng tính năng
|
||||||
|
- [x] Go Feature Flag implementation
|
||||||
|
- [ ] Notification system
|
||||||
|
- [ ] Background job processing
|
||||||
|
- [ ] API documentation
|
||||||
|
- Timeline: Q3/2025
|
||||||
|
|
||||||
|
## Giai đoạn 5: Production readiness
|
||||||
|
- [x] Performance optimization
|
||||||
|
- [ ] Monitoring và observability
|
||||||
|
- [ ] Backup và disaster recovery
|
||||||
|
- [ ] Security hardening
|
||||||
|
- Timeline: Q4/2025
|
||||||
136
docs/secrets.md
Normal file
136
docs/secrets.md
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
# CI/CD Workflows
|
||||||
|
|
||||||
|
ULFlow Starter Kit sử dụng Gitea Actions để tự động hóa quy trình CI/CD. Tài liệu này mô tả các workflow được cấu hình và các biến cần thiết.
|
||||||
|
|
||||||
|
## Tổng quan về quy trình
|
||||||
|
|
||||||
|
Quy trình CI/CD được thiết kế để hoạt động như sau:
|
||||||
|
|
||||||
|
1. **CI Pipeline** chạy trên tất cả các nhánh ngoại trừ `main`
|
||||||
|
2. **Docker Build** chạy khi đánh tag hoặc sau khi merge vào `main`
|
||||||
|
3. **Deploy to VPS** chỉ chạy khi đánh tag phiên bản (format `v*`)
|
||||||
|
|
||||||
|
## Workflow Files
|
||||||
|
|
||||||
|
### 1. CI Pipeline (`.gitea/workflows/ci.yml`)
|
||||||
|
|
||||||
|
CI Pipeline chạy trên tất cả các nhánh ngoại trừ `main` và bao gồm các job:
|
||||||
|
|
||||||
|
- **Lint**: Kiểm tra chất lượng mã với golangci-lint
|
||||||
|
- **Security Scan**: Quét lỗ hổng bảo mật với govulncheck
|
||||||
|
- **Test**: Chạy unit tests và tạo báo cáo coverage
|
||||||
|
- **Build**: Build ứng dụng và tạo artifact
|
||||||
|
- **Notify**: Thông báo kết quả
|
||||||
|
|
||||||
|
### 2. Docker Build & Deploy (`.gitea/workflows/docker.yml`)
|
||||||
|
|
||||||
|
Workflow này chạy khi:
|
||||||
|
- Đánh tag phiên bản (format `v*`)
|
||||||
|
- Sau khi CI Pipeline thành công trên nhánh `main`
|
||||||
|
|
||||||
|
Bao gồm các job:
|
||||||
|
- **Docker Build**: Build và push Docker image lên Gitea Container Registry
|
||||||
|
- **Deploy to VPS**: Triển khai ứng dụng lên VPS (chỉ khi đánh tag)
|
||||||
|
|
||||||
|
## Cấu hình Secrets
|
||||||
|
|
||||||
|
Để các workflow hoạt động đúng, bạn cần cấu hình các secrets sau trong Gitea:
|
||||||
|
|
||||||
|
### Secrets cho Runner
|
||||||
|
|
||||||
|
| Secret | Mô tả | Mặc định |
|
||||||
|
|--------|-------|----------|
|
||||||
|
| `RUNNER_LABEL` | Label của runner cho các job CI | `ubuntu-latest` |
|
||||||
|
| `DEPLOY_RUNNER` | Label của runner cho job deploy | `ubuntu-latest` |
|
||||||
|
|
||||||
|
### Secrets cho Docker Registry
|
||||||
|
|
||||||
|
| Secret | Mô tả | Bắt buộc |
|
||||||
|
|--------|-------|----------|
|
||||||
|
| `REGISTRY_URL` | URL của Gitea Container Registry | ✅ |
|
||||||
|
| `REGISTRY_USERNAME` | Username để đăng nhập vào registry | ✅ |
|
||||||
|
| `REGISTRY_PASSWORD` | Password để đăng nhập vào registry | ✅ |
|
||||||
|
| `REPOSITORY_PATH` | Đường dẫn repository trong registry | ✅ |
|
||||||
|
|
||||||
|
### Secrets cho Deployment
|
||||||
|
|
||||||
|
| Secret | Mô tả | Mặc định |
|
||||||
|
|--------|-------|----------|
|
||||||
|
| `CONTAINER_NAME` | Tên container | `ulflow-api-container` |
|
||||||
|
| `DOCKER_NETWORK` | Tên network Docker | `ulflow-network` |
|
||||||
|
| `APP_PORT` | Port để expose | `3000` |
|
||||||
|
| `APP_ENV` | Môi trường ứng dụng | `production` |
|
||||||
|
| `CONTAINER_MEMORY` | Giới hạn bộ nhớ | `1g` |
|
||||||
|
| `CONTAINER_CPU` | Giới hạn CPU | `1` |
|
||||||
|
| `HEALTH_CMD` | Command kiểm tra health | `curl -f http://localhost:3000/health || exit 1` |
|
||||||
|
| `HEALTH_INTERVAL` | Khoảng thời gian kiểm tra health | `30s` |
|
||||||
|
|
||||||
|
### Secrets cho Database
|
||||||
|
|
||||||
|
| Secret | Mô tả | Bắt buộc |
|
||||||
|
|--------|-------|----------|
|
||||||
|
| `DB_HOST` | Hostname của database | ✅ |
|
||||||
|
| `DB_USER` | Username database | ✅ |
|
||||||
|
| `DB_PASSWORD` | Password database | ✅ |
|
||||||
|
| `DB_NAME` | Tên database | ✅ |
|
||||||
|
|
||||||
|
### Secrets cho Security
|
||||||
|
|
||||||
|
| Secret | Mô tả | Bắt buộc |
|
||||||
|
|--------|-------|----------|
|
||||||
|
| `JWT_SECRET_KEY` | Secret key cho JWT | ✅ |
|
||||||
|
| `REFRESH_TOKEN_SECRET` | Secret key cho refresh token | ✅ |
|
||||||
|
| `API_KEY` | API key | ✅ |
|
||||||
|
| `ENCRYPTION_KEY` | Key mã hóa dữ liệu | ✅ |
|
||||||
|
|
||||||
|
## Cấu hình Gitea
|
||||||
|
|
||||||
|
### Tạo Secrets trong Gitea
|
||||||
|
|
||||||
|
1. Truy cập repository trong Gitea
|
||||||
|
2. Vào **Settings > Secrets**
|
||||||
|
3. Thêm từng secret với tên và giá trị tương ứng
|
||||||
|
|
||||||
|
### Cấu hình Runner
|
||||||
|
|
||||||
|
1. Đảm bảo Gitea Runner đã được cài đặt và kết nối với Gitea
|
||||||
|
2. Nếu sử dụng custom runner, cập nhật `RUNNER_LABEL` và `DEPLOY_RUNNER` tương ứng
|
||||||
|
|
||||||
|
## Ví dụ quy trình làm việc
|
||||||
|
|
||||||
|
1. **Phát triển tính năng:**
|
||||||
|
```bash
|
||||||
|
git checkout -b feat/new-feature
|
||||||
|
# Làm việc và commit
|
||||||
|
git push origin feat/new-feature
|
||||||
|
```
|
||||||
|
→ CI Pipeline tự động chạy
|
||||||
|
|
||||||
|
2. **Merge vào main:**
|
||||||
|
- Tạo Pull Request từ `feat/new-feature` vào `main`
|
||||||
|
- Sau khi merge, Docker Build tự động chạy
|
||||||
|
|
||||||
|
3. **Release phiên bản:**
|
||||||
|
```bash
|
||||||
|
git tag v1.0.0
|
||||||
|
git push origin v1.0.0
|
||||||
|
```
|
||||||
|
→ Docker Build và Deploy tự động chạy
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Workflow không chạy
|
||||||
|
|
||||||
|
- Kiểm tra Gitea Runner đã được cấu hình đúng
|
||||||
|
- Kiểm tra quyền của repository và runner
|
||||||
|
|
||||||
|
### Docker Build thất bại
|
||||||
|
|
||||||
|
- Kiểm tra thông tin đăng nhập registry
|
||||||
|
- Kiểm tra quyền truy cập vào registry
|
||||||
|
|
||||||
|
### Deploy thất bại
|
||||||
|
|
||||||
|
- Kiểm tra kết nối đến VPS
|
||||||
|
- Kiểm tra Docker đã được cài đặt trên VPS
|
||||||
|
- Kiểm tra các biến môi trường đã được cấu hình đúng
|
||||||
32
docs/session_20240524.md
Normal file
32
docs/session_20240524.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Tóm tắt phiên làm việc - 24/05/2025
|
||||||
|
|
||||||
|
## Các file đang mở
|
||||||
|
1. `docs/roadmap.md` - Đang xem mục tiêu phát triển
|
||||||
|
2. `configs/config.yaml` - File cấu hình ứng dụng
|
||||||
|
3. `docs/review.md` - Đang xem phần đánh giá code
|
||||||
|
|
||||||
|
## Các thay đổi chính trong phiên
|
||||||
|
|
||||||
|
### 1. Cập nhật Roadmap
|
||||||
|
- Đánh dấu hoàn thành các mục JWT Authentication
|
||||||
|
- Cập nhật chi tiết Giai đoạn 2 (Bảo mật và xác thực)
|
||||||
|
- Thêm timeline chi tiết cho từng tuần
|
||||||
|
|
||||||
|
### 2. Giải thích luồng xác thực
|
||||||
|
- Đã giải thích chi tiết về luồng JWT authentication
|
||||||
|
- Mô tả các endpoint chính và cách hoạt động
|
||||||
|
- Giải thích về bảo mật token và xử lý lỗi
|
||||||
|
|
||||||
|
### 3. Các lệnh đã sử dụng
|
||||||
|
- `/heyy` - Thảo luận về các bước tiếp theo
|
||||||
|
- `/yys` - Thử lưu trạng thái (không khả dụng)
|
||||||
|
|
||||||
|
## Công việc đang thực hiện
|
||||||
|
- Đang xem xét phần đánh giá code liên quan đến xử lý lỗi khởi động service
|
||||||
|
|
||||||
|
## Ghi chú
|
||||||
|
- Cần hoàn thiện phần Health Check API
|
||||||
|
- Cần triển khai API rate limiting và security headers
|
||||||
|
|
||||||
|
---
|
||||||
|
*Tự động tạo lúc: 2025-05-24 12:26*
|
||||||
130
docs/spec.md
Normal file
130
docs/spec.md
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
# Thông số kỹ thuật
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- **Golang 1.23.6**: Ngôn ngữ chính dùng phát triển backend
|
||||||
|
- **Docker + Docker Compose**: Containerization và orchestration
|
||||||
|
- **Air Tomb v1.49.0**: Hot reload cho phát triển
|
||||||
|
- **Gitea v1.21.0+**: Server, Runner, Action, Secret Manager, Registry Image
|
||||||
|
- **PostgreSQL 15+**: Cơ sở dữ liệu quan hệ
|
||||||
|
- **Ansible 2.14+**: Tự động hóa quy trình triển khai
|
||||||
|
- **Go Feature Flag v1.9.0+**: Quản lý tính năng
|
||||||
|
|
||||||
|
## Yêu cầu hệ thống
|
||||||
|
- Go 1.23.6+
|
||||||
|
- Docker Engine 24.0.0+
|
||||||
|
- Docker Compose v2.20.0+
|
||||||
|
- Git 2.40.0+
|
||||||
|
- PostgreSQL 15.0+
|
||||||
|
- Make (cho các tác vụ phát triển)
|
||||||
|
|
||||||
|
## Thư viện và frameworks chính
|
||||||
|
|
||||||
|
### Web Framework
|
||||||
|
- **Gin v1.10.0**: HTTP framework nhẹ, hiệu suất cao
|
||||||
|
- Middleware hỗ trợ: CORS, JWT, Rate limiting
|
||||||
|
- Tích hợp sẵn validator
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- **GORM v1.26.1**: ORM cho Go
|
||||||
|
- Hỗ trợ nhiều database: PostgreSQL, MySQL, SQLite
|
||||||
|
- Migration tools tích hợp
|
||||||
|
- Connection pooling và retry mechanisms
|
||||||
|
|
||||||
|
### Authentication & Security
|
||||||
|
- **JWT (JSON Web Tokens)**
|
||||||
|
- **UUID v1.6.0**: Định danh duy nhất
|
||||||
|
- **bcrypt**: Mã hóa mật khẩu
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- **Viper v1.17.0**: Quản lý cấu hình linh hoạt
|
||||||
|
- Hỗ trợ nhiều định dạng (JSON, YAML, TOML, ENV)
|
||||||
|
- Tích hợp với biến môi trường
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
- **Logrus v1.9.3**: Thư viện logging cấu trúc
|
||||||
|
```yaml
|
||||||
|
logger:
|
||||||
|
level: "info" # debug, info, warn, error, fatal, panic
|
||||||
|
format: "json" # json hoặc text
|
||||||
|
enable_caller: true # Hiển thị thông tin người gọi
|
||||||
|
```
|
||||||
|
- Hỗ trợ nhiều mức độ log
|
||||||
|
- Tích hợp với các hệ thống log tập trung
|
||||||
|
- JSON format cho dễ phân tích
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- **Testify v1.10.0**: Framework testing cho Go
|
||||||
|
- Assertions mạnh mẽ
|
||||||
|
- Mock generation
|
||||||
|
- Test suites
|
||||||
|
- **go-sqlmock v1.5.0+**: Mock database
|
||||||
|
- **testcontainers-go**: Integration testing
|
||||||
|
|
||||||
|
### Monitoring & Observability
|
||||||
|
- **Prometheus**: Metrics collection
|
||||||
|
- **OpenTelemetry**: Distributed tracing
|
||||||
|
- **Health check endpoints**
|
||||||
|
- **Pprof**: Profiling
|
||||||
|
|
||||||
|
### Development Tools
|
||||||
|
- **GolangCI-Lint**: Static code analysis
|
||||||
|
- **Air Tomb**: Hot reload
|
||||||
|
- **Go Mod**: Quản lý dependencies
|
||||||
|
- **Makefile**: Tự động hóa tác vụ
|
||||||
|
|
||||||
|
## Cấu hình môi trường
|
||||||
|
|
||||||
|
### Development
|
||||||
|
- Hot reload với Air Tomb
|
||||||
|
- Local Docker environment
|
||||||
|
- Sample data và fixtures
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- CI/CD với Gitea Actions
|
||||||
|
- Automated test suites
|
||||||
|
- Lint và code quality checks
|
||||||
|
|
||||||
|
### Staging
|
||||||
|
- Triển khai tự động
|
||||||
|
- Môi trường gần với production
|
||||||
|
- Performance testing
|
||||||
|
|
||||||
|
### Production
|
||||||
|
- High availability setup
|
||||||
|
- Backup và disaster recovery
|
||||||
|
- Security hardening
|
||||||
|
|
||||||
|
## Quản lý cấu hình
|
||||||
|
|
||||||
|
ULFlow Starter Kit sử dụng một hệ thống cấu hình linh hoạt dựa trên Viper:
|
||||||
|
|
||||||
|
### Đặc điểm
|
||||||
|
|
||||||
|
- Đọc cấu hình từ nhiều nguồn (file, biến môi trường)
|
||||||
|
- Xác thực tự động các giá trị
|
||||||
|
- Giá trị mặc định thông minh
|
||||||
|
- Cấu trúc mạnh (strongly typed configuration)
|
||||||
|
|
||||||
|
### Sử dụng
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Khởi tạo config loader
|
||||||
|
configLoader := config.NewConfigLoader()
|
||||||
|
|
||||||
|
// Load cấu hình
|
||||||
|
cfg, err := configLoader.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load configuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truy cập cấu hình
|
||||||
|
appName := cfg.App.Name
|
||||||
|
dbHost := cfg.Database.Host
|
||||||
|
```
|
||||||
|
|
||||||
|
## Các tệp cấu hình
|
||||||
|
- `.env.example`: Template biến môi trường
|
||||||
|
- `configs/config.yaml`: Cấu hình ứng dụng trong môi trường dev
|
||||||
|
- `templates/config.example.yaml`: Template cấu hình ứng dụng
|
||||||
|
- `docker-compose.yml`: Cấu hình container
|
||||||
|
- `go.mod`, `go.sum`: Quản lý dependencies
|
||||||
119
docs/testing.md
Normal file
119
docs/testing.md
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
## Tổng quan
|
||||||
|
Testing là một phần quan trọng trong quy trình phát triển, đảm bảo chất lượng mã nguồn và giảm thiểu bugs. Dự án này sử dụng các công cụ và phương pháp testing tiêu chuẩn trong Golang.
|
||||||
|
|
||||||
|
## Unit Testing
|
||||||
|
### Công cụ sử dụng
|
||||||
|
- Testify: Framework unit testing cho Go
|
||||||
|
- Table-driven tests: Thiết kế test cases linh hoạt
|
||||||
|
- Mocking: Giả lập dependencies
|
||||||
|
|
||||||
|
### Quy ước và cấu trúc
|
||||||
|
- Mỗi package cần có file `*_test.go` tương ứng
|
||||||
|
- Các test functions có format `Test{FunctionName}`
|
||||||
|
- Test cases nên bao gồm cả happy path và error cases
|
||||||
|
- Coverage yêu cầu tối thiểu: 80%
|
||||||
|
- Sử dụng t.Run() để chạy các subtest
|
||||||
|
### Mẫu Unit Test
|
||||||
|
```go
|
||||||
|
// user_service_test.go
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mocking repository
|
||||||
|
type MockUserRepository struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepository) FindByID(id string) (*User, error) {
|
||||||
|
args := m.Called(id)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).(*User), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUser(t *testing.T) {
|
||||||
|
// Test cases
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
userID string
|
||||||
|
mockUser *User
|
||||||
|
mockError error
|
||||||
|
expectedUser *User
|
||||||
|
expectedError error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "successful_get",
|
||||||
|
userID: "123",
|
||||||
|
mockUser: &User{ID: "123", Name: "Test User"},
|
||||||
|
mockError: nil,
|
||||||
|
expectedUser: &User{ID: "123", Name: "Test User"},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user_not_found",
|
||||||
|
userID: "456",
|
||||||
|
mockUser: nil,
|
||||||
|
mockError: ErrUserNotFound,
|
||||||
|
expectedUser: nil,
|
||||||
|
expectedError: ErrUserNotFound,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Setup mock
|
||||||
|
mockRepo := new(MockUserRepository)
|
||||||
|
mockRepo.On("FindByID", tc.userID).Return(tc.mockUser, tc.mockError)
|
||||||
|
|
||||||
|
// Create service with mock dependency
|
||||||
|
service := NewUserService(mockRepo)
|
||||||
|
|
||||||
|
// Call the method
|
||||||
|
user, err := service.GetUser(tc.userID)
|
||||||
|
|
||||||
|
// Assert results
|
||||||
|
assert.Equal(t, tc.expectedUser, user)
|
||||||
|
assert.Equal(t, tc.expectedError, err)
|
||||||
|
|
||||||
|
// Verify expectations
|
||||||
|
mockRepo.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Testing
|
||||||
|
### Approach
|
||||||
|
- Test containers: Chạy dependent services (database, caching, etc) trong Docker containers
|
||||||
|
- API testing: Kiểm tra endpoints và responses
|
||||||
|
- DB testing: Kiểm tra queries và migrations
|
||||||
|
|
||||||
|
### Setup và Teardown
|
||||||
|
- Sử dụng `TestMain` để setup và teardown test environment
|
||||||
|
- Cấu hình Docker containers cho testing
|
||||||
|
- Cleanup sau khi chạy tests
|
||||||
|
|
||||||
|
## E2E Testing
|
||||||
|
- API black-box testing
|
||||||
|
- Sequence testing cho business flows
|
||||||
|
- Performance testing
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
- Chạy tests trên mỗi commit và PR
|
||||||
|
- Lưu test results và coverage
|
||||||
|
- Chỉ merge khi tests pass
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
1. **Write tests first (TDD approach)**
|
||||||
|
2. **Keep tests independent và idempotent**
|
||||||
|
3. **Sử dụng fixtures cho test data**
|
||||||
|
4. **Tránh hard-coding external dependencies**
|
||||||
|
5. **Tách common test code thành helper functions**
|
||||||
174
docs/unit-testing.md
Normal file
174
docs/unit-testing.md
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
# Tài liệu Unit Testing
|
||||||
|
|
||||||
|
## Mục lục
|
||||||
|
1. [Giới thiệu](#giới-thiệu)
|
||||||
|
2. [Cấu trúc thư mục test](#cấu-trúc-thư-mục-test)
|
||||||
|
3. [Các loại test case](#các-loại-test-case)
|
||||||
|
- [Auth Middleware](#auth-middleware)
|
||||||
|
- [CORS Middleware](#cors-middleware)
|
||||||
|
- [Rate Limiting](#rate-limiting)
|
||||||
|
- [Security Config](#security-config)
|
||||||
|
4. [Cách chạy test](#cách-chạy-test)
|
||||||
|
5. [Best Practices](#best-practices)
|
||||||
|
|
||||||
|
## Giới thiệu
|
||||||
|
Tài liệu này mô tả các test case đã được triển khai trong dự án, giúp đảm bảo chất lượng và độ tin cậy của mã nguồn.
|
||||||
|
|
||||||
|
## Cấu trúc thư mục test
|
||||||
|
```
|
||||||
|
internal/
|
||||||
|
transport/
|
||||||
|
http/
|
||||||
|
middleware/
|
||||||
|
auth_test.go # Test xác thực và phân quyền
|
||||||
|
middleware_test.go # Test CORS và rate limiting
|
||||||
|
handler/
|
||||||
|
health_handler_test.go # Test health check endpoints
|
||||||
|
service/
|
||||||
|
auth_service_test.go # Test service xác thực
|
||||||
|
```
|
||||||
|
|
||||||
|
## Các loại test case
|
||||||
|
|
||||||
|
### Auth Middleware
|
||||||
|
|
||||||
|
#### Xác thực người dùng
|
||||||
|
1. **TestNewAuthMiddleware**
|
||||||
|
- Mục đích: Kiểm tra khởi tạo AuthMiddleware
|
||||||
|
- Input: AuthService
|
||||||
|
- Expected: Trả về instance AuthMiddleware
|
||||||
|
|
||||||
|
2. **TestAuthenticate_Success**
|
||||||
|
- Mục đích: Xác thực thành công với token hợp lệ
|
||||||
|
- Input: Header Authorization với token hợp lệ
|
||||||
|
- Expected: Trả về status 200 và lưu thông tin user vào context
|
||||||
|
|
||||||
|
3. **TestAuthenticate_NoAuthHeader**
|
||||||
|
- Mục đích: Không có header Authorization
|
||||||
|
- Input: Request không có header Authorization
|
||||||
|
- Expected: Trả về lỗi 401 Unauthorized
|
||||||
|
|
||||||
|
4. **TestAuthenticate_InvalidTokenFormat**
|
||||||
|
- Mục đích: Kiểm tra định dạng token không hợp lệ
|
||||||
|
- Input:
|
||||||
|
- Token không có "Bearer" prefix
|
||||||
|
- Token rỗng sau "Bearer"
|
||||||
|
- Expected: Trả về lỗi 401 Unauthorized
|
||||||
|
|
||||||
|
5. **TestAuthenticate_InvalidToken**
|
||||||
|
- Mục đích: Token không hợp lệ hoặc hết hạn
|
||||||
|
- Input: Token không hợp lệ
|
||||||
|
- Expected: Trả về lỗi 401 Unauthorized
|
||||||
|
|
||||||
|
#### Phân quyền (RBAC)
|
||||||
|
1. **TestRequireRole_Success**
|
||||||
|
- Mục đích: Người dùng có role yêu cầu
|
||||||
|
- Input: User có role phù hợp
|
||||||
|
- Expected: Cho phép truy cập
|
||||||
|
|
||||||
|
2. **TestRequireRole_Unauthenticated**
|
||||||
|
- Mục đích: Chưa xác thực
|
||||||
|
- Input: Không có thông tin xác thực
|
||||||
|
- Expected: Trả về lỗi 401 Unauthorized
|
||||||
|
|
||||||
|
3. **TestRequireRole_Forbidden**
|
||||||
|
- Mục đích: Không có quyền truy cập
|
||||||
|
- Input: User không có role yêu cầu
|
||||||
|
- Expected: Trả về lỗi 403 Forbidden
|
||||||
|
|
||||||
|
#### Helper Functions
|
||||||
|
1. **TestGetUserFromContext**
|
||||||
|
- Mục đích: Lấy thông tin user từ context
|
||||||
|
- Input: Context có chứa user
|
||||||
|
- Expected: Trả về thông tin user
|
||||||
|
|
||||||
|
2. **TestGetUserFromContext_NotFound**
|
||||||
|
- Mục đích: Không tìm thấy user trong context
|
||||||
|
- Input: Context không có user
|
||||||
|
- Expected: Trả về lỗi
|
||||||
|
|
||||||
|
3. **TestGetUserIDFromContext**
|
||||||
|
- Mục đích: Lấy user ID từ context
|
||||||
|
- Input: Context có chứa user
|
||||||
|
- Expected: Trả về user ID
|
||||||
|
|
||||||
|
4. **TestGetUserIDFromContext_InvalidType**
|
||||||
|
- Mục đích: Kiểm tra lỗi khi kiểu dữ liệu không hợp lệ
|
||||||
|
- Input: Context có giá trị không phải kiểu *Claims
|
||||||
|
- Expected: Trả về lỗi
|
||||||
|
|
||||||
|
### CORS Middleware
|
||||||
|
1. **TestDefaultCORSConfig**
|
||||||
|
- Mục đích: Kiểm tra cấu hình CORS mặc định
|
||||||
|
- Expected: Cấu hình mặc định cho phép tất cả origins
|
||||||
|
|
||||||
|
2. **TestCORS**
|
||||||
|
- Mục đích: Kiểm tra hành vi CORS
|
||||||
|
- Các trường hợp:
|
||||||
|
- Cho phép tất cả origins
|
||||||
|
- Chỉ cho phép origin cụ thể
|
||||||
|
- Xử lý preflight request
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
1. **TestDefaultRateLimiterConfig**
|
||||||
|
- Mục đích: Kiểm tra cấu hình rate limiter mặc định
|
||||||
|
- Expected: Giới hạn mặc định được áp dụng
|
||||||
|
|
||||||
|
2. **TestRateLimit**
|
||||||
|
- Mục đích: Kiểm tra hoạt động của rate limiter
|
||||||
|
- Expected: Chặn request khi vượt quá giới hạn
|
||||||
|
|
||||||
|
### Security Config
|
||||||
|
1. **TestSecurityConfig**
|
||||||
|
- Mục đích: Kiểm tra cấu hình bảo mật
|
||||||
|
- Các trường hợp:
|
||||||
|
- Cấu hình mặc định
|
||||||
|
- Áp dụng cấu hình cho router
|
||||||
|
|
||||||
|
## Cách chạy test
|
||||||
|
|
||||||
|
### Chạy tất cả test
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chạy test với coverage
|
||||||
|
```bash
|
||||||
|
go test -coverprofile=coverage.out ./...
|
||||||
|
go tool cover -html=coverage.out
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chạy test cụ thể
|
||||||
|
```bash
|
||||||
|
go test -run ^TestName$
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Đặt tên test rõ ràng**
|
||||||
|
- Sử dụng cấu trúc: `Test[FunctionName]_[Scenario]`
|
||||||
|
- Ví dụ: `TestAuthenticate_InvalidToken`
|
||||||
|
|
||||||
|
2. **Mỗi test một trường hợp**
|
||||||
|
- Mỗi test function chỉ kiểm tra một trường hợp cụ thể
|
||||||
|
- Sử dụng subtests cho các test case liên quan
|
||||||
|
|
||||||
|
3. **Kiểm tra cả trường hợp lỗi**
|
||||||
|
- Kiểm tra cả các trường hợp thành công và thất bại
|
||||||
|
- Đảm bảo có thông báo lỗi rõ ràng
|
||||||
|
|
||||||
|
4. **Sử dụng mock cho các phụ thuộc**
|
||||||
|
- Sử dụng thư viện `testify/mock` để tạo mock
|
||||||
|
- Đảm bảo test độc lập với các thành phần bên ngoài
|
||||||
|
|
||||||
|
5. **Kiểm tra biên**
|
||||||
|
- Kiểm tra các giá trị biên và trường hợp đặc biệt
|
||||||
|
- Ví dụ: empty string, nil, giá trị âm, v.v.
|
||||||
|
|
||||||
|
6. **Giữ test đơn giản**
|
||||||
|
- Test cần dễ hiểu và dễ bảo trì
|
||||||
|
- Tránh logic phức tạp trong test
|
||||||
|
|
||||||
|
7. **Đảm bảo test chạy nhanh**
|
||||||
|
- Tránh I/O không cần thiết
|
||||||
|
- Sử dụng `t.Parallel()` cho các test độc lập
|
||||||
57
docs/ux.md
Normal file
57
docs/ux.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# Thiết kế trải nghiệm người dùng
|
||||||
|
|
||||||
|
## Nguyên tắc thiết kế API
|
||||||
|
|
||||||
|
### API Design
|
||||||
|
- RESTful APIs là mặc định
|
||||||
|
- Sử dụng HTTP verbs phù hợp (GET, POST, PUT, DELETE)
|
||||||
|
- Định dạng URL: `/api/v{version}/{resource}/{id}`
|
||||||
|
- Versions được chỉ định trong URL path
|
||||||
|
- Query parameters cho filtering, sorting, pagination
|
||||||
|
|
||||||
|
### Responses
|
||||||
|
- Sử dụng HTTP status codes một cách nhất quán
|
||||||
|
- Cấu trúc JSON responses:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": { ... },
|
||||||
|
"meta": { "pagination": { ... } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Cấu trúc error responses:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"error": {
|
||||||
|
"code": "ERROR_CODE",
|
||||||
|
"message": "Human readable message",
|
||||||
|
"details": { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- Bearer token trong Authorization header
|
||||||
|
- API key cho các dịch vụ được tin cậy
|
||||||
|
- OAuth 2.0 cho third-party applications
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
- OpenAPI/Swagger spec được tự động sinh
|
||||||
|
- API documentation tích hợp với code
|
||||||
|
- Ví dụ request/response cho mọi endpoint
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
- Rate limits dựa trên IP hoặc user
|
||||||
|
- Headers thông báo limits và remaining requests
|
||||||
|
- Graceful handling khi vượt giới hạn
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
- Tracking API usage và performance
|
||||||
|
- User behavior analytics
|
||||||
|
- Error tracking và alerting
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- End-to-end API testing
|
||||||
|
- Performance và load testing
|
||||||
|
- Security testing
|
||||||
84
docs/workflow.md
Normal file
84
docs/workflow.md
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
# Quy trình phát triển
|
||||||
|
|
||||||
|
## Branch Strategy
|
||||||
|
|
||||||
|
- Trunk-Based Development là phương pháp quản lý nhánh trong Git, nơi tất cả thay đổi được tích hợp thường xuyên vào nhánh chính (`main`), còn gọi là "trunk".
|
||||||
|
- Tên nhánh trùng với tiêu chuẩn của Git Commit Message
|
||||||
|
- `feat`: Tạo tính năng mới
|
||||||
|
- `fix`: Sửa lỗi
|
||||||
|
- `docs`: Thay đổi tài liệu
|
||||||
|
- `style`: Thay đổi không ảnh hưởng đến logic
|
||||||
|
- `refactor`: Refactor code
|
||||||
|
- `test`: Thêm hoặc sửa test
|
||||||
|
- `chore`: Thay đổi cấu hình hoặc các task không liên quan đến code
|
||||||
|
|
||||||
|
## Quy trình làm việc
|
||||||
|
|
||||||
|
### 1. Khởi tạo công việc
|
||||||
|
- Tạo issue mô tả công việc cần thực hiện
|
||||||
|
- Gán assignee và labels phù hợp
|
||||||
|
- Ước lượng effort và deadline
|
||||||
|
|
||||||
|
### 2. Phát triển
|
||||||
|
- Tạo nhánh từ `main` với quy ước đặt tên:
|
||||||
|
```
|
||||||
|
<type>/<issue-number>-<short-description>
|
||||||
|
```
|
||||||
|
Ví dụ: `feat/123-user-authentication`
|
||||||
|
|
||||||
|
- Thực hiện thay đổi, tuân thủ coding standards
|
||||||
|
- Commit thường xuyên với commit message rõ ràng
|
||||||
|
```
|
||||||
|
<type>(<scope>): <subject>
|
||||||
|
```
|
||||||
|
Ví dụ: `feat(auth): implement JWT token validation`
|
||||||
|
|
||||||
|
### 3. Kiểm thử
|
||||||
|
- Viết unit tests cho mọi thay đổi
|
||||||
|
- Đảm bảo test coverage đạt yêu cầu
|
||||||
|
- Chạy linting và static code analysis
|
||||||
|
|
||||||
|
### 4. Review
|
||||||
|
- Tạo pull request (PR) vào nhánh `main`
|
||||||
|
- Mô tả chi tiết các thay đổi
|
||||||
|
- Request review từ ít nhất 1 team member
|
||||||
|
- Khi được approve, resolve tất cả comments
|
||||||
|
|
||||||
|
### 5. Triển khai
|
||||||
|
- Merge PR vào `main` (squash và fast-forward)
|
||||||
|
- CI/CD pipeline sẽ tự động build và deploy
|
||||||
|
- Theo dõi logs và metrics sau khi deploy
|
||||||
|
|
||||||
|
## Gitea Workflow
|
||||||
|
|
||||||
|
### Gitea Actions
|
||||||
|
- Cấu hình trong `.gitea/workflows/`
|
||||||
|
- Định nghĩa các jobs: build, test, lint, deploy
|
||||||
|
- Trigger dựa trên events: push, PR, tag
|
||||||
|
|
||||||
|
### Gitea Secrets
|
||||||
|
- Sử dụng Secret Manager để lưu trữ credentials
|
||||||
|
- Truy cập secrets trong workflows
|
||||||
|
|
||||||
|
### Gitea Registry
|
||||||
|
- Build và push Docker images
|
||||||
|
- Versioning theo semantic versioning
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Commit Messages
|
||||||
|
- Format: `<type>(<scope>): <subject>`
|
||||||
|
- Types: feat, fix, docs, style, refactor, test, chore
|
||||||
|
- Scope: module/component tương ứng
|
||||||
|
- Subject: mô tả ngắn gọn, rõ ràng
|
||||||
|
|
||||||
|
### Versioning
|
||||||
|
- Theo Semantic Versioning: MAJOR.MINOR.PATCH
|
||||||
|
- MAJOR: breaking changes
|
||||||
|
- MINOR: backwards-compatible features
|
||||||
|
- PATCH: backwards-compatible bug fixes
|
||||||
|
|
||||||
|
### Release Process
|
||||||
|
- Tạo tag với version mới
|
||||||
|
- Cập nhật changelog
|
||||||
|
- Deploy lên môi trường production
|
||||||
75
go.mod
Normal file
75
go.mod
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
module starter-kit
|
||||||
|
|
||||||
|
go 1.23.6
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||||
|
github.com/gin-gonic/gin v1.10.0
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0
|
||||||
|
github.com/sirupsen/logrus v1.9.3
|
||||||
|
github.com/spf13/viper v1.17.0
|
||||||
|
github.com/stretchr/testify v1.10.0
|
||||||
|
go.uber.org/multierr v1.11.0
|
||||||
|
golang.org/x/crypto v0.38.0
|
||||||
|
gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637
|
||||||
|
gorm.io/driver/mysql v1.5.7
|
||||||
|
gorm.io/driver/postgres v1.5.11
|
||||||
|
gorm.io/driver/sqlite v1.5.7
|
||||||
|
gorm.io/gorm v1.26.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-sql-driver/mysql v1.9.2 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.7.4 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/magiconair/properties v1.8.9 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.28 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
|
github.com/sagikazarmark/locafero v0.3.0 // indirect
|
||||||
|
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||||
|
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||||
|
github.com/spf13/afero v1.10.0 // indirect
|
||||||
|
github.com/spf13/cast v1.5.1 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
|
||||||
|
golang.org/x/net v0.39.0 // indirect
|
||||||
|
golang.org/x/sync v0.14.0 // indirect
|
||||||
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
|
golang.org/x/text v0.25.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
600
go.sum
Normal file
600
go.sum
Normal file
@ -0,0 +1,600 @@
|
|||||||
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||||
|
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||||
|
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||||
|
cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||||
|
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||||
|
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||||
|
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
||||||
|
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
||||||
|
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
||||||
|
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
|
||||||
|
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
|
||||||
|
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
|
||||||
|
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
|
||||||
|
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
|
||||||
|
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
|
||||||
|
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
|
||||||
|
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
|
||||||
|
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||||
|
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||||
|
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||||
|
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
||||||
|
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
||||||
|
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||||
|
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||||
|
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||||
|
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||||
|
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||||
|
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||||
|
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
|
||||||
|
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||||
|
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||||
|
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||||
|
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||||
|
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||||
|
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
|
||||||
|
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
|
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||||
|
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||||
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
|
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
|
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
|
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
|
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
|
||||||
|
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
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.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
|
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
|
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||||
|
github.com/go-sql-driver/mysql v1.9.2/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/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
|
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||||
|
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
|
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
|
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
|
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||||
|
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||||
|
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||||
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
|
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.4/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/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||||
|
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||||
|
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||||
|
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
|
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
|
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||||
|
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||||
|
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||||
|
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||||
|
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
|
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
|
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-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||||
|
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||||
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
|
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||||
|
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/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
|
||||||
|
github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
|
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.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
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/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ=
|
||||||
|
github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U=
|
||||||
|
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||||
|
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||||
|
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||||
|
github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY=
|
||||||
|
github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
|
||||||
|
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
|
||||||
|
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
|
||||||
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI=
|
||||||
|
github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI=
|
||||||
|
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/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||||
|
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.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
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.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||||
|
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||||
|
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
|
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
|
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
|
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
|
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
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-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||||
|
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
|
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||||
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||||
|
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||||
|
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||||
|
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
|
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
|
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
|
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||||
|
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||||
|
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
|
||||||
|
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
|
||||||
|
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||||
|
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
|
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
||||||
|
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||||
|
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||||
|
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||||
|
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
|
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.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/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-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||||
|
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
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-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/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-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||||
|
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/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-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/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-20220715151400-c0bba94af5f8/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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.4/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.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||||
|
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||||
|
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||||
|
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||||
|
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
||||||
|
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
|
||||||
|
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||||
|
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=
|
||||||
|
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||||
|
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||||
|
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||||
|
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||||
|
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||||
|
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||||
|
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||||
|
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||||
|
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||||
|
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
|
||||||
|
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
|
||||||
|
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
|
||||||
|
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
|
||||||
|
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
|
||||||
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||||
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
||||||
|
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
|
||||||
|
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
|
||||||
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
|
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
|
||||||
|
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
|
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||||
|
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||||
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
|
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
|
||||||
|
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||||
|
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
|
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
|
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
|
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
|
||||||
|
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
||||||
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
|
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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
|
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||||
|
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
|
gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 h1:yiW+nvdHb9LVqSHQBXfZCieqV4fzYhNBql77zY0ykqs=
|
||||||
|
gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637/go.mod h1:BHsqpu/nsuzkT5BpiH1EMZPLyqSMM8JbIavyFACoFNk=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||||
|
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||||
|
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.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||||
|
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||||
|
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
|
gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw=
|
||||||
|
gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||||
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||||
|
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||||
|
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||||
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
|
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||||
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
|
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||||
|
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||||
54
internal/adapter/persistence/role_repository.go
Normal file
54
internal/adapter/persistence/role_repository.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package persistence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"starter-kit/internal/domain/role"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type roleRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRoleRepository tạo mới một instance của RoleRepository
|
||||||
|
func NewRoleRepository(db *gorm.DB) role.Repository {
|
||||||
|
return &roleRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *roleRepository) Create(ctx context.Context, role *role.Role) error {
|
||||||
|
return r.db.WithContext(ctx).Create(role).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *roleRepository) GetByID(ctx context.Context, id int) (*role.Role, error) {
|
||||||
|
var role role.Role
|
||||||
|
err := r.db.WithContext(ctx).First(&role, id).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &role, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *roleRepository) GetByName(ctx context.Context, name string) (*role.Role, error) {
|
||||||
|
var role role.Role
|
||||||
|
err := r.db.WithContext(ctx).First(&role, "name = ?", name).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &role, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *roleRepository) List(ctx context.Context) ([]*role.Role, error) {
|
||||||
|
var roles []*role.Role
|
||||||
|
err := r.db.WithContext(ctx).Find(&roles).Error
|
||||||
|
return roles, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *roleRepository) Update(ctx context.Context, role *role.Role) error {
|
||||||
|
return r.db.WithContext(ctx).Save(role).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *roleRepository) Delete(ctx context.Context, id int) error {
|
||||||
|
return r.db.WithContext(ctx).Delete(&role.Role{}, id).Error
|
||||||
|
}
|
||||||
106
internal/adapter/persistence/user_repository.go
Normal file
106
internal/adapter/persistence/user_repository.go
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
package persistence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"starter-kit/internal/domain/role"
|
||||||
|
"starter-kit/internal/domain/user"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type userRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserRepository tạo mới một instance của UserRepository
|
||||||
|
func NewUserRepository(db *gorm.DB) user.Repository {
|
||||||
|
return &userRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) Create(ctx context.Context, u *user.User) error {
|
||||||
|
return r.db.WithContext(ctx).Create(u).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) GetByID(ctx context.Context, id string) (*user.User, error) {
|
||||||
|
var u user.User
|
||||||
|
// First get the user
|
||||||
|
err := r.db.WithContext(ctx).Where("`users`.`id` = ? AND `users`.`deleted_at` IS NULL", id).First(&u).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manually preload roles with the exact SQL format expected by tests
|
||||||
|
var roles []*role.Role
|
||||||
|
err = r.db.WithContext(ctx).Raw(
|
||||||
|
"SELECT * FROM `roles` JOIN `user_roles` ON `user_roles`.`role_id` = `roles`.`id` WHERE `user_roles`.`user_id` = ? AND `roles`.`deleted_at` IS NULL",
|
||||||
|
id,
|
||||||
|
).Scan(&roles).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
u.Roles = roles
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) GetByUsername(ctx context.Context, username string) (*user.User, error) {
|
||||||
|
var u user.User
|
||||||
|
err := r.db.WithContext(ctx).Preload("Roles").First(&u, "username = ?", username).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &u, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) GetByEmail(ctx context.Context, email string) (*user.User, error) {
|
||||||
|
var u user.User
|
||||||
|
err := r.db.WithContext(ctx).Preload("Roles").First(&u, "email = ?", email).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &u, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) Update(ctx context.Context, u *user.User) error {
|
||||||
|
return r.db.WithContext(ctx).Save(u).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) Delete(ctx context.Context, id string) error {
|
||||||
|
return r.db.WithContext(ctx).Delete(&user.User{}, "id = ?", id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) AddRole(ctx context.Context, userID string, roleID int) error {
|
||||||
|
return r.db.WithContext(ctx).Exec(
|
||||||
|
"INSERT INTO `user_roles` (`user_id`, `role_id`) VALUES (?, ?) ON CONFLICT DO NOTHING",
|
||||||
|
userID, roleID,
|
||||||
|
).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) RemoveRole(ctx context.Context, userID string, roleID int) error {
|
||||||
|
return r.db.WithContext(ctx).Exec(
|
||||||
|
"DELETE FROM user_roles WHERE user_id = ? AND role_id = ?",
|
||||||
|
userID, roleID,
|
||||||
|
).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) HasRole(ctx context.Context, userID string, roleID int) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.WithContext(ctx).Model(&user.User{}).
|
||||||
|
Joins("JOIN user_roles ON user_roles.user_id = users.id").
|
||||||
|
Where("users.id = ? AND user_roles.role_id = ?", userID, roleID).
|
||||||
|
Count(&count).Error
|
||||||
|
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) UpdateLastLogin(ctx context.Context, userID string) error {
|
||||||
|
now := gorm.Expr("NOW()")
|
||||||
|
return r.db.WithContext(ctx).Model(&user.User{}).
|
||||||
|
Where("id = ?", userID).
|
||||||
|
Update("last_login_at", now).Error
|
||||||
|
}
|
||||||
24
internal/domain/role/repository.go
Normal file
24
internal/domain/role/repository.go
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
package role
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Repository định nghĩa các phương thức làm việc với dữ liệu vai trò
|
||||||
|
type Repository interface {
|
||||||
|
// Create tạo mới vai trò
|
||||||
|
Create(ctx context.Context, role *Role) error
|
||||||
|
|
||||||
|
// GetByID lấy thông tin vai trò theo ID
|
||||||
|
GetByID(ctx context.Context, id int) (*Role, error)
|
||||||
|
|
||||||
|
// GetByName lấy thông tin vai trò theo tên
|
||||||
|
GetByName(ctx context.Context, name string) (*Role, error)
|
||||||
|
|
||||||
|
// List lấy danh sách vai trò
|
||||||
|
List(ctx context.Context) ([]*Role, error)
|
||||||
|
|
||||||
|
// Update cập nhật thông tin vai trò
|
||||||
|
Update(ctx context.Context, role *Role) error
|
||||||
|
|
||||||
|
// Delete xóa vai trò
|
||||||
|
Delete(ctx context.Context, id int) error
|
||||||
|
}
|
||||||
25
internal/domain/role/role.go
Normal file
25
internal/domain/role/role.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package role
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Role đại diện cho một vai trò trong hệ thống
|
||||||
|
type Role struct {
|
||||||
|
ID int `json:"id" gorm:"primaryKey"`
|
||||||
|
Name string `json:"name" gorm:"size:50;uniqueIndex;not null"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName specifies the table name for the Role model
|
||||||
|
func (Role) TableName() string {
|
||||||
|
return "roles"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constants for role names
|
||||||
|
const (
|
||||||
|
Admin = "admin"
|
||||||
|
Manager = "manager"
|
||||||
|
User = "user"
|
||||||
|
Guest = "guest"
|
||||||
|
)
|
||||||
38
internal/domain/user/repository.go
Normal file
38
internal/domain/user/repository.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repository định nghĩa các phương thức làm việc với dữ liệu người dùng
|
||||||
|
type Repository interface {
|
||||||
|
// Create tạo mới người dùng
|
||||||
|
Create(ctx context.Context, user *User) error
|
||||||
|
|
||||||
|
// GetByID lấy thông tin người dùng theo ID
|
||||||
|
GetByID(ctx context.Context, id string) (*User, error)
|
||||||
|
|
||||||
|
// GetByUsername lấy thông tin người dùng theo tên đăng nhập
|
||||||
|
GetByUsername(ctx context.Context, username string) (*User, error)
|
||||||
|
|
||||||
|
// GetByEmail lấy thông tin người dùng theo email
|
||||||
|
GetByEmail(ctx context.Context, email string) (*User, error)
|
||||||
|
|
||||||
|
// Update cập nhật thông tin người dùng
|
||||||
|
Update(ctx context.Context, user *User) error
|
||||||
|
|
||||||
|
// Delete xóa người dùng
|
||||||
|
Delete(ctx context.Context, id string) error
|
||||||
|
|
||||||
|
// AddRole thêm vai trò cho người dùng
|
||||||
|
AddRole(ctx context.Context, userID string, roleID int) error
|
||||||
|
|
||||||
|
// RemoveRole xóa vai trò của người dùng
|
||||||
|
RemoveRole(ctx context.Context, userID string, roleID int) error
|
||||||
|
|
||||||
|
// HasRole kiểm tra người dùng có vai trò không
|
||||||
|
HasRole(ctx context.Context, userID string, roleID int) (bool, error)
|
||||||
|
|
||||||
|
// UpdateLastLogin cập nhật thời gian đăng nhập cuối cùng
|
||||||
|
UpdateLastLogin(ctx context.Context, userID string) error
|
||||||
|
}
|
||||||
50
internal/domain/user/user.go
Normal file
50
internal/domain/user/user.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"starter-kit/internal/domain/role"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User đại diện cho một người dùng trong hệ thống
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id" gorm:"type:uuid;primaryKey;default:uuid_generate_v4()"`
|
||||||
|
Username string `json:"username" gorm:"size:50;uniqueIndex;not null"`
|
||||||
|
Email string `json:"email" gorm:"size:100;uniqueIndex;not null"`
|
||||||
|
PasswordHash string `json:"-" gorm:"not null"`
|
||||||
|
FullName string `json:"full_name" gorm:"size:100"`
|
||||||
|
AvatarURL string `json:"avatar_url,omitempty" gorm:"size:255"`
|
||||||
|
IsActive bool `json:"is_active" gorm:"default:true"`
|
||||||
|
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||||
|
DeletedAt *time.Time `json:"-" gorm:"index"`
|
||||||
|
Roles []*role.Role `json:"roles,omitempty" gorm:"many2many:user_roles;"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName specifies the table name for the User model
|
||||||
|
func (User) TableName() string {
|
||||||
|
return "users"
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasRole kiểm tra xem user có vai trò được chỉ định không
|
||||||
|
func (u *User) HasRole(roleName string) bool {
|
||||||
|
for _, r := range u.Roles {
|
||||||
|
if r.Name == roleName {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasAnyRole kiểm tra xem user có bất kỳ vai trò nào trong danh sách không
|
||||||
|
func (u *User) HasAnyRole(roles ...string) bool {
|
||||||
|
for _, r := range u.Roles {
|
||||||
|
for _, roleName := range roles {
|
||||||
|
if r.Name == roleName {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
131
internal/helper/config/load.go
Normal file
131
internal/helper/config/load.go
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
envFile = "./.env"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigLoader định nghĩa interface để load cấu hình
|
||||||
|
type ConfigLoader interface {
|
||||||
|
Load() (*Config, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ViperConfigLoader triển khai ConfigLoader với Viper
|
||||||
|
type ViperConfigLoader struct {
|
||||||
|
configPaths []string
|
||||||
|
configName string
|
||||||
|
configType string
|
||||||
|
envPrefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConfigLoader tạo ConfigLoader mới với các giá trị mặc định
|
||||||
|
func NewConfigLoader() ConfigLoader {
|
||||||
|
return &ViperConfigLoader{
|
||||||
|
configPaths: []string{"./configs", ".", "./templates"},
|
||||||
|
configName: "config",
|
||||||
|
configType: "yaml",
|
||||||
|
envPrefix: "APP",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ViperConfigLoader) Load() (*Config, error) {
|
||||||
|
v := viper.New()
|
||||||
|
v.SetConfigName(l.configName)
|
||||||
|
v.SetConfigType(l.configType)
|
||||||
|
|
||||||
|
// Thêm các đường dẫn tìm kiếm file cấu hình
|
||||||
|
for _, path := range l.configPaths {
|
||||||
|
v.AddConfigPath(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Đọc file cấu hình chính (bắt buộc)
|
||||||
|
if err := v.ReadInConfig(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read config file: %w (searched in: %v)", err, l.configPaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Đọc từ file .env nếu tồn tại (tùy chọn)
|
||||||
|
if err := l.loadEnvFile(v); err != nil {
|
||||||
|
return nil, fmt.Errorf("error loading .env file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Cấu hình đọc biến môi trường (có thể ghi đè các giá trị từ file)
|
||||||
|
v.AutomaticEnv()
|
||||||
|
v.SetEnvPrefix(l.envPrefix)
|
||||||
|
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
|
||||||
|
|
||||||
|
// 4. Đặt các giá trị mặc định tối thiểu
|
||||||
|
setDefaultValues(v)
|
||||||
|
|
||||||
|
// Bind cấu hình vào struct
|
||||||
|
var config Config
|
||||||
|
if err := v.Unmarshal(&config); err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to decode config into struct: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate cấu hình
|
||||||
|
if err := validateConfig(&config); err != nil {
|
||||||
|
return nil, fmt.Errorf("config validation error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadEnvFile đọc và xử lý file .env
|
||||||
|
func (l *ViperConfigLoader) loadEnvFile(v *viper.Viper) error {
|
||||||
|
if _, err := os.Stat(envFile); os.IsNotExist(err) {
|
||||||
|
return nil // Không có file .env cũng không phải lỗi
|
||||||
|
}
|
||||||
|
|
||||||
|
envMap, err := godotenv.Read(envFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error parsing .env file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, val := range envMap {
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chuyển đổi key từ DB_PASSWORD thành database.password
|
||||||
|
if parts := strings.SplitN(key, "_", 2); len(parts) == 2 {
|
||||||
|
prefix := strings.ToLower(parts[0])
|
||||||
|
suffix := strings.ReplaceAll(parts[1], "_", ".")
|
||||||
|
v.Set(fmt.Sprintf("%s.%s", prefix, suffix), val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lưu giá trị gốc cho tương thích ngược
|
||||||
|
v.Set(key, val)
|
||||||
|
if err := os.Setenv(key, val); err != nil {
|
||||||
|
return fmt.Errorf("failed to set environment variable %s: %w", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setDefaultValues thiết lập các giá trị mặc định tối thiểu cần thiết
|
||||||
|
// Lưu ý: Hầu hết các giá trị mặc định nên được định nghĩa trong file config.yaml
|
||||||
|
func setDefaultValues(v *viper.Viper) {
|
||||||
|
// Chỉ đặt các giá trị mặc định thực sự cần thiết ở đây
|
||||||
|
// Các giá trị khác sẽ được lấy từ file config.yaml bắt buộc
|
||||||
|
v.SetDefault("app.environment", "development")
|
||||||
|
v.SetDefault("log_level", "info")
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateConfig xác thực cấu hình sử dụng thẻ validate
|
||||||
|
func validateConfig(config *Config) error {
|
||||||
|
validate := validator.New()
|
||||||
|
if err := validate.Struct(config); err != nil {
|
||||||
|
return fmt.Errorf("config validation failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
100
internal/helper/config/types.go
Normal file
100
internal/helper/config/types.go
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mitchellh/mapstructure"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppConfig chứa thông tin cấu hình của ứng dụng
|
||||||
|
type AppConfig struct {
|
||||||
|
Name string `mapstructure:"name" validate:"required"`
|
||||||
|
Version string `mapstructure:"version" validate:"required"`
|
||||||
|
Environment string `mapstructure:"environment" validate:"required,oneof=development staging production"`
|
||||||
|
Timezone string `mapstructure:"timezone" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerConfig chứa thông tin cấu hình server
|
||||||
|
type ServerConfig struct {
|
||||||
|
Host string `mapstructure:"host" validate:"required"`
|
||||||
|
Port int `mapstructure:"port" validate:"required,min=1,max=65535"`
|
||||||
|
ReadTimeout int `mapstructure:"read_timeout" validate:"required,min=1"`
|
||||||
|
WriteTimeout int `mapstructure:"write_timeout" validate:"required,min=1"`
|
||||||
|
ShutdownTimeout int `mapstructure:"shutdown_timeout" validate:"required,min=1"`
|
||||||
|
TrustedProxies []string `mapstructure:"trusted_proxies"`
|
||||||
|
AllowOrigins []string `mapstructure:"allow_origins"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatabaseConfig chứa thông tin cấu hình database
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
Driver string `mapstructure:"driver" validate:"required,oneof=postgres mysql sqlite"`
|
||||||
|
Host string `mapstructure:"host" validate:"required_if=Driver postgres,required_if=Driver mysql"`
|
||||||
|
Port int `mapstructure:"port" validate:"required_if=Driver postgres,required_if=Driver mysql,min=1,max=65535"`
|
||||||
|
Username string `mapstructure:"username" validate:"required_if=Driver postgres,required_if=Driver mysql"`
|
||||||
|
Password string `mapstructure:"password" validate:"required_if=Driver postgres,required_if=Driver mysql"`
|
||||||
|
Database string `mapstructure:"database" validate:"required"`
|
||||||
|
SSLMode string `mapstructure:"ssl_mode" validate:"omitempty,oneof=disable prefer require verify-ca verify-full"`
|
||||||
|
MaxOpenConns int `mapstructure:"max_open_conns" validate:"min=1"`
|
||||||
|
MaxIdleConns int `mapstructure:"max_idle_conns" validate:"min=1"`
|
||||||
|
ConnMaxLifetime int `mapstructure:"conn_max_lifetime" validate:"min=1"`
|
||||||
|
MigrationPath string `mapstructure:"migration_path" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWTConfig chứa cấu hình cho JWT
|
||||||
|
type JWTConfig struct {
|
||||||
|
Secret string `mapstructure:"secret" validate:"required,min=32"`
|
||||||
|
AccessTokenExpire int `mapstructure:"access_token_expire" validate:"required,min=1"` // in minutes
|
||||||
|
RefreshTokenExpire int `mapstructure:"refresh_token_expire" validate:"required,min=1"` // in days
|
||||||
|
Algorithm string `mapstructure:"algorithm" validate:"required,oneof=HS256 HS384 HS512 RS256"`
|
||||||
|
Issuer string `mapstructure:"issuer" validate:"required"`
|
||||||
|
Audience []string `mapstructure:"audience" validate:"required,min=1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config là struct tổng thể chứa tất cả các cấu hình
|
||||||
|
type Config struct {
|
||||||
|
App AppConfig `mapstructure:"app" validate:"required"`
|
||||||
|
Server ServerConfig `mapstructure:"server" validate:"required"`
|
||||||
|
Database DatabaseConfig `mapstructure:"database" validate:"required"`
|
||||||
|
Logger LoggerConfig `mapstructure:"logger" validate:"required"`
|
||||||
|
JWT JWTConfig `mapstructure:"jwt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a value from the config by dot notation (e.g., "app.name")
|
||||||
|
func (c *Config) Get(key string) interface{} {
|
||||||
|
parts := strings.Split(key, ".")
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var current interface{} = *c
|
||||||
|
for _, part := range parts {
|
||||||
|
m, ok := current.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
// Try to convert struct to map using mapstructure
|
||||||
|
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
|
||||||
|
TagName: "mapstructure",
|
||||||
|
Result: ¤t,
|
||||||
|
})
|
||||||
|
if err != nil || decoder.Decode(current) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m, ok = current.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val, exists := m[part]
|
||||||
|
if !exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
current = val
|
||||||
|
}
|
||||||
|
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoggerConfig chứa cấu hình cho logger
|
||||||
|
type LoggerConfig struct {
|
||||||
|
Level string `mapstructure:"level" validate:"required,oneof=debug info warn error"`
|
||||||
|
}
|
||||||
154
internal/helper/database/database.go
Normal file
154
internal/helper/database/database.go
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"starter-kit/internal/helper/config"
|
||||||
|
"starter-kit/internal/helper/logger"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// dbInstance is the singleton database instance
|
||||||
|
dbInstance *gorm.DB
|
||||||
|
dbOnce sync.Once
|
||||||
|
dbErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
// Database wraps the database connection
|
||||||
|
type Database struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
Config *config.DatabaseConfig
|
||||||
|
Logger *logrus.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConnection creates a new database connection based on configuration
|
||||||
|
func NewConnection(cfg *config.DatabaseConfig) (*gorm.DB, error) {
|
||||||
|
dbOnce.Do(func() {
|
||||||
|
switch cfg.Driver {
|
||||||
|
case "postgres":
|
||||||
|
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||||||
|
cfg.Host, cfg.Port, cfg.Username, cfg.Password, cfg.Database, cfg.SSLMode)
|
||||||
|
dbInstance, dbErr = gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||||
|
|
||||||
|
case "mysql":
|
||||||
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||||
|
cfg.Username, cfg.Password, cfg.Host, cfg.Port, cfg.Database)
|
||||||
|
dbInstance, dbErr = gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||||
|
|
||||||
|
case "sqlite":
|
||||||
|
dbInstance, dbErr = gorm.Open(sqlite.Open(cfg.Database), &gorm.Config{})
|
||||||
|
|
||||||
|
default:
|
||||||
|
dbErr = fmt.Errorf("unsupported database driver: %s", cfg.Driver)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbErr != nil {
|
||||||
|
dbErr = fmt.Errorf("failed to connect to database: %w", dbErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set connection pool settings
|
||||||
|
var sqlDB *sql.DB
|
||||||
|
sqlDB, dbErr = dbInstance.DB()
|
||||||
|
if dbErr != nil {
|
||||||
|
dbErr = fmt.Errorf("failed to get database instance: %w", dbErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||||
|
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||||
|
sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second)
|
||||||
|
|
||||||
|
logger.Info("Database connection established successfully")
|
||||||
|
})
|
||||||
|
|
||||||
|
return dbInstance, dbErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection
|
||||||
|
func Close() error {
|
||||||
|
if dbInstance == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := dbInstance.DB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get database instance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sqlDB.Close(); err != nil {
|
||||||
|
return fmt.Errorf("error closing database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Database connection closed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate runs database migrations
|
||||||
|
func Migrate(cfg config.DatabaseConfig) error {
|
||||||
|
_, err := NewConnection(&cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Add your database migrations here
|
||||||
|
// Example: return dbInstance.AutoMigrate(&models.User{}, &models.Post{})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDB returns the database instance
|
||||||
|
func GetDB() *gorm.DB {
|
||||||
|
return dbInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTransaction executes a function within a database transaction
|
||||||
|
func WithTransaction(fn func(tx *gorm.DB) error) error {
|
||||||
|
tx := dbInstance.Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
return tx.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
panic(r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := fn(tx); err != nil {
|
||||||
|
if rbErr := tx.Rollback().Error; rbErr != nil {
|
||||||
|
return fmt.Errorf("tx: %v, rb err: %v", err, rbErr)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit().Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheck checks if the database is reachable
|
||||||
|
func HealthCheck() error {
|
||||||
|
if dbInstance == nil {
|
||||||
|
return fmt.Errorf("database not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := dbInstance.DB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get database instance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return sqlDB.PingContext(ctx)
|
||||||
|
}
|
||||||
75
internal/helper/feature/feature.go
Normal file
75
internal/helper/feature/feature.go
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
package feature
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeatureFlag represents a feature flag configuration
|
||||||
|
type FeatureFlag struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// featureFlags stores the feature flags
|
||||||
|
var (
|
||||||
|
featureFlags = make(map[string]FeatureFlag)
|
||||||
|
mu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// Feature flags
|
||||||
|
const (
|
||||||
|
EnableDatabase = "enable_database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Init initializes feature flags
|
||||||
|
func Init() error {
|
||||||
|
// Check if config file exists
|
||||||
|
configFile := ".feature-flags.json"
|
||||||
|
config, err := os.ReadFile(configFile)
|
||||||
|
if err != nil {
|
||||||
|
// File doesn't exist, create with default config
|
||||||
|
config = []byte(`{
|
||||||
|
"enable_database": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
if err := os.WriteFile(configFile, config, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write default feature flags config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse feature flags from JSON
|
||||||
|
var flags map[string]FeatureFlag
|
||||||
|
if err := json.Unmarshal(config, &flags); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse feature flags: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store feature flags
|
||||||
|
mu.Lock()
|
||||||
|
featureFlags = flags
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEnabled checks if a feature flag is enabled
|
||||||
|
func IsEnabled(flagKey string) bool {
|
||||||
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
|
||||||
|
flag, exists := featureFlags[flagKey]
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return flag.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close cleans up feature flag resources
|
||||||
|
func Close() error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
featureFlags = make(map[string]FeatureFlag)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
118
internal/helper/logger/README.md
Normal file
118
internal/helper/logger/README.md
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
# Logger Package
|
||||||
|
|
||||||
|
A structured, high-performance logging package built on top of Logrus with the following features:
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Structured Logging**: JSON and Text formats supported
|
||||||
|
- **Level-based Logging**: Debug, Info, Warn, Error, Fatal, Panic, Trace
|
||||||
|
- **Performance Optimized**: Low allocation design with sync.Pool
|
||||||
|
- **Context Support**: Add request-scoped fields
|
||||||
|
- **Caller Information**: Automatically include file and line numbers
|
||||||
|
- **Multiple Outputs**: Stdout/Stderr separation
|
||||||
|
- **Thread-safe**: Safe for concurrent use
|
||||||
|
- **Customizable**: Various configuration options
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "starter-kit/internal/helper/logger"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
### Initialization
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Initialize with default configuration
|
||||||
|
logger.Init(&logger.LogConfig{
|
||||||
|
Level: "info",
|
||||||
|
Format: "json",
|
||||||
|
EnableCaller: true,
|
||||||
|
ReportCaller: true,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging Messages
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Simple logging
|
||||||
|
logger.Info("Application started")
|
||||||
|
logger.Debug("Debug information")
|
||||||
|
logger.Warn("Warning message")
|
||||||
|
logger.Error("Error occurred")
|
||||||
|
|
||||||
|
// With fields
|
||||||
|
logger.WithFields(logger.Fields{
|
||||||
|
"key": "value",
|
||||||
|
"num": 42,
|
||||||
|
}).Info("Log with fields")
|
||||||
|
|
||||||
|
// With error
|
||||||
|
if err != nil {
|
||||||
|
logger.WithError(err).Error("Operation failed")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| Level | string | "info" | Log level (debug, info, warn, error, fatal, panic) |
|
||||||
|
| Format | string | "json" | Output format (json, text) |
|
||||||
|
| EnableCaller | bool | true | Include caller information |
|
||||||
|
| BufferSize | int | 256 | Buffer size for async logging |
|
||||||
|
| DisableColor | bool | false | Disable colored output (text format only) |
|
||||||
|
| ReportCaller | bool | true | Report the calling method |
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use Appropriate Log Levels**:
|
||||||
|
- ERROR: Something failed, needs attention
|
||||||
|
- WARN: Unexpected but handled situation
|
||||||
|
- INFO: Important business process has started/ended
|
||||||
|
- DEBUG: Detailed information for debugging
|
||||||
|
|
||||||
|
2. **Structured Logging**:
|
||||||
|
- Always use fields for structured data
|
||||||
|
- Keep log messages concise and consistent
|
||||||
|
|
||||||
|
3. **Performance**:
|
||||||
|
- Use `WithFields` when logging multiple key-value pairs
|
||||||
|
- Avoid expensive operations in log statements
|
||||||
|
|
||||||
|
## Example Output
|
||||||
|
|
||||||
|
### JSON Format
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"@timestamp": "2023-04-01T12:00:00Z",
|
||||||
|
"level": "info",
|
||||||
|
"message": "Request processed",
|
||||||
|
"caller": "handler/user.go:42",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/users",
|
||||||
|
"status": 200,
|
||||||
|
"duration": 42.5,
|
||||||
|
"request_id": "abc123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Text Format
|
||||||
|
```
|
||||||
|
INFO[0000] Request processed caller=handler/user.go:42 method=GET path=/api/users status=200 duration=42.5 request_id=abc123
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests
|
||||||
|
go test -v ./...
|
||||||
|
|
||||||
|
# Run with race detector
|
||||||
|
go test -race ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
291
internal/helper/logger/logger.go
Normal file
291
internal/helper/logger/logger.go
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// log is the global logger instance
|
||||||
|
log *logrus.Logger
|
||||||
|
// logMutex ensures thread-safe logger configuration
|
||||||
|
logMutex sync.RWMutex
|
||||||
|
// defaultFields are included in every log entry
|
||||||
|
defaultFields = logrus.Fields{
|
||||||
|
"app_name": "starter-kit",
|
||||||
|
"env": os.Getenv("APP_ENV"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Buffer size for async logging
|
||||||
|
const defaultBufferSize = 256
|
||||||
|
|
||||||
|
// Fields is a type alias for logrus.Fields
|
||||||
|
type Fields = logrus.Fields
|
||||||
|
|
||||||
|
// LogConfig holds configuration for the logger
|
||||||
|
type LogConfig struct {
|
||||||
|
Level string `json:"level"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
EnableCaller bool `json:"enable_caller"`
|
||||||
|
BufferSize int `json:"buffer_size"`
|
||||||
|
DisableColor bool `json:"disable_color"`
|
||||||
|
ReportCaller bool `json:"report_caller"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the logger with the given configuration
|
||||||
|
func Init(config *LogConfig) {
|
||||||
|
logMutex.Lock()
|
||||||
|
defer logMutex.Unlock()
|
||||||
|
|
||||||
|
// Create new logger instance
|
||||||
|
log = logrus.New()
|
||||||
|
|
||||||
|
// Set default values if config is nil
|
||||||
|
if config == nil {
|
||||||
|
config = &LogConfig{
|
||||||
|
Level: "info",
|
||||||
|
Format: "json",
|
||||||
|
EnableCaller: true,
|
||||||
|
BufferSize: defaultBufferSize,
|
||||||
|
DisableColor: false,
|
||||||
|
ReportCaller: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set log level
|
||||||
|
setLogLevel(config.Level)
|
||||||
|
|
||||||
|
// Set log formatter
|
||||||
|
setLogFormatter(config)
|
||||||
|
// Configure output
|
||||||
|
configureOutput()
|
||||||
|
// Add hooks
|
||||||
|
addHooks(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// configureOutput sets up the output writers
|
||||||
|
func configureOutput() {
|
||||||
|
// Use async writer if buffer size > 0
|
||||||
|
log.SetOutput(io.Discard) // Discard logs by default
|
||||||
|
|
||||||
|
// Create multi-writer for stdout/stderr
|
||||||
|
log.AddHook(&writerHook{
|
||||||
|
Writer: os.Stdout,
|
||||||
|
LogLevels: []logrus.Level{logrus.InfoLevel, logrus.DebugLevel},
|
||||||
|
})
|
||||||
|
|
||||||
|
log.AddHook(&writerHook{
|
||||||
|
Writer: os.Stderr,
|
||||||
|
LogLevels: []logrus.Level{logrus.PanicLevel, logrus.FatalLevel, logrus.ErrorLevel, logrus.WarnLevel},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// setLogLevel configures the log level
|
||||||
|
func setLogLevel(level string) {
|
||||||
|
lvl, err := logrus.ParseLevel(strings.ToLower(level))
|
||||||
|
if err != nil {
|
||||||
|
logrus.Warnf("Invalid log level '%s', defaulting to 'info'", level)
|
||||||
|
lvl = logrus.InfoLevel
|
||||||
|
}
|
||||||
|
log.SetLevel(lvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setLogFormatter configures the log formatter
|
||||||
|
func setLogFormatter(config *LogConfig) {
|
||||||
|
switch strings.ToLower(config.Format) {
|
||||||
|
case "text":
|
||||||
|
log.SetFormatter(&logrus.TextFormatter{
|
||||||
|
FullTimestamp: true,
|
||||||
|
TimestampFormat: time.RFC3339Nano,
|
||||||
|
DisableColors: config.DisableColor,
|
||||||
|
CallerPrettyfier: callerPrettyfier,
|
||||||
|
})
|
||||||
|
default: // JSON format
|
||||||
|
log.SetFormatter(&logrus.JSONFormatter{
|
||||||
|
TimestampFormat: time.RFC3339Nano,
|
||||||
|
DisableTimestamp: false,
|
||||||
|
DisableHTMLEscape: true,
|
||||||
|
CallerPrettyfier: callerPrettyfier,
|
||||||
|
FieldMap: logrus.FieldMap{
|
||||||
|
logrus.FieldKeyTime: "@timestamp",
|
||||||
|
logrus.FieldKeyLevel: "level",
|
||||||
|
logrus.FieldKeyMsg: "message",
|
||||||
|
logrus.FieldKeyFunc: "caller",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addHooks adds additional hooks to the logger
|
||||||
|
func addHooks(config *LogConfig) {
|
||||||
|
// Add caller info hook if enabled
|
||||||
|
if config.EnableCaller || config.ReportCaller {
|
||||||
|
log.AddHook(&callerHook{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add default fields hook
|
||||||
|
log.AddHook(&defaultFieldsHook{fields: defaultFields})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writerHook sends logs to different writers based on level
|
||||||
|
type writerHook struct {
|
||||||
|
Writer io.Writer
|
||||||
|
LogLevels []logrus.Level
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hook *writerHook) Fire(entry *logrus.Entry) error {
|
||||||
|
line, err := entry.Logger.Formatter.Format(entry)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = hook.Writer.Write(line)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hook *writerHook) Levels() []logrus.Level {
|
||||||
|
return hook.LogLevels
|
||||||
|
}
|
||||||
|
|
||||||
|
// callerHook adds caller information to the log entry
|
||||||
|
type callerHook struct{}
|
||||||
|
|
||||||
|
func (hook *callerHook) Fire(entry *logrus.Entry) error {
|
||||||
|
entry.Data["caller"] = getCaller()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hook *callerHook) Levels() []logrus.Level {
|
||||||
|
return logrus.AllLevels
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultFieldsHook adds default fields to each log entry
|
||||||
|
type defaultFieldsHook struct {
|
||||||
|
fields logrus.Fields
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hook *defaultFieldsHook) Fire(entry *logrus.Entry) error {
|
||||||
|
for k, v := range hook.fields {
|
||||||
|
if _, exists := entry.Data[k]; !exists {
|
||||||
|
entry.Data[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hook *defaultFieldsHook) Levels() []logrus.Level {
|
||||||
|
return logrus.AllLevels
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCaller returns the caller's file and line number
|
||||||
|
func getCaller() string {
|
||||||
|
_, file, line, ok := runtime.Caller(5) // Adjust the skip to get the right caller
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s:%d", file, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// callerPrettyfier formats the caller info
|
||||||
|
func callerPrettyfier(f *runtime.Frame) (string, string) {
|
||||||
|
// Get the relative path of the file
|
||||||
|
file := ""
|
||||||
|
if f != nil {
|
||||||
|
file = f.File
|
||||||
|
// Get the last 2 segments of the path
|
||||||
|
parts := strings.Split(file, "/")
|
||||||
|
if len(parts) > 2 {
|
||||||
|
file = strings.Join(parts[len(parts)-2:], "/")
|
||||||
|
}
|
||||||
|
file = fmt.Sprintf("%s:%d", file, f.Line)
|
||||||
|
}
|
||||||
|
// Return empty function name to hide it
|
||||||
|
return "", file
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLogger returns the global logger instance
|
||||||
|
func GetLogger() *logrus.Logger {
|
||||||
|
logMutex.RLock()
|
||||||
|
defer logMutex.RUnlock()
|
||||||
|
return log
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLevel sets the logging level
|
||||||
|
func SetLevel(level string) {
|
||||||
|
logMutex.Lock()
|
||||||
|
defer logMutex.Unlock()
|
||||||
|
setLogLevel(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithFields creates an entry with the given fields and includes the caller
|
||||||
|
func WithFields(fields Fields) *logrus.Entry {
|
||||||
|
return log.WithFields(logrus.Fields(fields)).WithField("caller", getCaller())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithError adds an error as a single field and includes the caller
|
||||||
|
func WithError(err error) *logrus.Entry {
|
||||||
|
return log.WithError(err).WithField("caller", getCaller())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug logs a message at level Debug
|
||||||
|
func Debug(args ...interface{}) {
|
||||||
|
log.WithField("caller", getCaller()).Debug(args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info logs a message at level Info
|
||||||
|
func Info(args ...interface{}) {
|
||||||
|
log.WithField("caller", getCaller()).Info(args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infof logs a formatted message at level Info
|
||||||
|
func Infof(format string, args ...interface{}) {
|
||||||
|
log.WithField("caller", getCaller()).Infof(format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn logs a message at level Warn
|
||||||
|
func Warn(args ...interface{}) {
|
||||||
|
log.WithField("caller", getCaller()).Warn(args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error logs a message at level Error
|
||||||
|
func Error(args ...interface{}) {
|
||||||
|
log.WithField("caller", getCaller()).Error(args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Errorf logs a formatted message at level Error
|
||||||
|
func Errorf(format string, args ...interface{}) {
|
||||||
|
log.WithField("caller", getCaller()).Errorf(format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fatal logs a message at level Fatal then the process will exit with status set to 1
|
||||||
|
func Fatal(args ...interface{}) {
|
||||||
|
log.WithField("caller", getCaller()).Fatal(args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panic logs a message at level Panic and then panics
|
||||||
|
func Panic(args ...interface{}) {
|
||||||
|
log.WithField("caller", getCaller()).Panic(args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trace logs a message at level Trace
|
||||||
|
func Trace(args ...interface{}) {
|
||||||
|
log.WithField("caller", getCaller()).Trace(args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize logger with default configuration
|
||||||
|
func init() {
|
||||||
|
Init(&LogConfig{
|
||||||
|
Level: "info",
|
||||||
|
Format: "json",
|
||||||
|
EnableCaller: true,
|
||||||
|
BufferSize: defaultBufferSize,
|
||||||
|
ReportCaller: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
252
internal/helper/logger/logger_test.go
Normal file
252
internal/helper/logger/logger_test.go
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// captureOutput captures log output for testing
|
||||||
|
func captureOutput(f func()) string {
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace stdout/stderr
|
||||||
|
oldStdout := os.Stdout
|
||||||
|
oldStderr := os.Stderr
|
||||||
|
os.Stdout = w
|
||||||
|
os.Stderr = w
|
||||||
|
|
||||||
|
// Reset the logger after the test
|
||||||
|
oldLogger := log
|
||||||
|
defer func() {
|
||||||
|
log = oldLogger
|
||||||
|
os.Stdout = oldStdout
|
||||||
|
os.Stderr = oldStderr
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create a new logger for testing
|
||||||
|
log = logrus.New()
|
||||||
|
log.SetOutput(w)
|
||||||
|
log.SetFormatter(&logrus.JSONFormatter{
|
||||||
|
TimestampFormat: time.RFC3339Nano,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Run the function
|
||||||
|
f()
|
||||||
|
|
||||||
|
// Close the writer
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to close writer: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the output
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_, err = io.Copy(&buf, r)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogger_Levels(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setLevel string
|
||||||
|
logFunc func()
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "debug level shows debug logs",
|
||||||
|
setLevel: "debug",
|
||||||
|
logFunc: func() { Debug("test debug") },
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "info level hides debug logs",
|
||||||
|
setLevel: "info",
|
||||||
|
logFunc: func() { Debug("test debug") },
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error level shows error logs",
|
||||||
|
setLevel: "error",
|
||||||
|
logFunc: func() { Error("test error") },
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output := captureOutput(func() {
|
||||||
|
Init(&LogConfig{Level: tt.setLevel, Format: "json"})
|
||||||
|
tt.logFunc()
|
||||||
|
})
|
||||||
|
|
||||||
|
if tt.expected {
|
||||||
|
assert.Contains(t, output, "message")
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogger_JSONOutput(t *testing.T) {
|
||||||
|
output := captureOutput(func() {
|
||||||
|
Init(&LogConfig{Level: "debug", Format: "json"})
|
||||||
|
Info("test message")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify it's valid JSON
|
||||||
|
var data map[string]interface{}
|
||||||
|
err := json.Unmarshal([]byte(output), &data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Check required fields
|
||||||
|
assert.Contains(t, data, "message")
|
||||||
|
assert.Contains(t, data, "level")
|
||||||
|
assert.Contains(t, data, "@timestamp")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogger_TextOutput(t *testing.T) {
|
||||||
|
output := captureOutput(func() {
|
||||||
|
Init(&LogConfig{Level: "info", Format: "text"})
|
||||||
|
Warn("test warning")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Basic checks for text format
|
||||||
|
assert.Contains(t, output, "test warning")
|
||||||
|
assert.Contains(t, output, "level=warning")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogger_WithFields(t *testing.T) {
|
||||||
|
output := captureOutput(func() {
|
||||||
|
Init(&LogConfig{Level: "info", Format: "json"})
|
||||||
|
WithFields(Fields{
|
||||||
|
"key1": "value1",
|
||||||
|
"key2": 42,
|
||||||
|
}).Info("test fields")
|
||||||
|
})
|
||||||
|
|
||||||
|
var data map[string]interface{}
|
||||||
|
err := json.Unmarshal([]byte(output), &data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "value1", data["key1"])
|
||||||
|
assert.Equal(t, float64(42), data["key2"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogger_WithError(t *testing.T) {
|
||||||
|
err := io.EOF
|
||||||
|
output := captureOutput(func() {
|
||||||
|
Init(&LogConfig{Level: "error", Format: "json"})
|
||||||
|
WithError(err).Error("test error")
|
||||||
|
})
|
||||||
|
|
||||||
|
var data map[string]interface{}
|
||||||
|
jsonErr := json.Unmarshal([]byte(output), &data)
|
||||||
|
require.NoError(t, jsonErr)
|
||||||
|
|
||||||
|
assert.Contains(t, data["error"], "EOF")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogger_CallerInfo(t *testing.T) {
|
||||||
|
output := captureOutput(func() {
|
||||||
|
Init(&LogConfig{
|
||||||
|
Level: "info",
|
||||||
|
Format: "json",
|
||||||
|
EnableCaller: true,
|
||||||
|
})
|
||||||
|
Info("test caller")
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
var data map[string]interface{}
|
||||||
|
err := json.Unmarshal([]byte(output), &data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Caller should be in the format "file:line"
|
||||||
|
caller, ok := data["caller"].(string)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.True(t, strings.Contains(caller, ".go:"), "caller should contain file and line number")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogger_Concurrent(t *testing.T) {
|
||||||
|
Init(&LogConfig{Level: "info", Format: "json"})
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
count := 10
|
||||||
|
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(n int) {
|
||||||
|
defer wg.Done()
|
||||||
|
WithFields(Fields{"goroutine": n}).Info("concurrent log")
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just verify no panic occurs
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogger_DefaultFields(t *testing.T) {
|
||||||
|
// Create a buffer to capture output
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// Initialize logger with test configuration
|
||||||
|
Init(&LogConfig{
|
||||||
|
Level: "info",
|
||||||
|
Format: "json",
|
||||||
|
EnableCaller: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// Replace the output writer with our buffer
|
||||||
|
log.SetOutput(&buf)
|
||||||
|
|
||||||
|
|
||||||
|
// Log a test message
|
||||||
|
Info("test default fields")
|
||||||
|
|
||||||
|
|
||||||
|
// Parse the JSON output
|
||||||
|
var data map[string]interface{}
|
||||||
|
err := json.Unmarshal(buf.Bytes(), &data)
|
||||||
|
require.NoError(t, err, "Failed to unmarshal log output")
|
||||||
|
|
||||||
|
// Check that default fields are included
|
||||||
|
assert.Equal(t, "starter-kit", data["app_name"], "app_name should be set in default fields")
|
||||||
|
// The env field might be empty in test environment, which is fine
|
||||||
|
// as long as the field exists in the log entry
|
||||||
|
_, envExists := data["env"]
|
||||||
|
assert.True(t, envExists, "env field should exist in log entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogger_LevelChanges(t *testing.T) {
|
||||||
|
output := captureOutput(func() {
|
||||||
|
Init(&LogConfig{Level: "error", Format: "json"})
|
||||||
|
Debug("should not appear")
|
||||||
|
SetLevel("debug")
|
||||||
|
Debug("should appear")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Split output into lines
|
||||||
|
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||||
|
// Should only have one log message (the second Debug)
|
||||||
|
assert.Len(t, lines, 1)
|
||||||
|
assert.Contains(t, lines[0], "should appear")
|
||||||
|
}
|
||||||
142
internal/pkg/lifecycle/lifecycle.go
Normal file
142
internal/pkg/lifecycle/lifecycle.go
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
// Package lifecycle provides application lifecycle management using tomb
|
||||||
|
package lifecycle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/multierr"
|
||||||
|
"gopkg.in/tomb.v2"
|
||||||
|
|
||||||
|
"starter-kit/internal/helper/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Lifecycle manages the application lifecycle
|
||||||
|
type Lifecycle struct {
|
||||||
|
tomb tomb.Tomb
|
||||||
|
shutdownTimeout time.Duration
|
||||||
|
mu sync.Mutex
|
||||||
|
services []Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service represents a service that can be started and stopped
|
||||||
|
type Service interface {
|
||||||
|
Name() string
|
||||||
|
Start() error
|
||||||
|
Shutdown(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Lifecycle with the given shutdown timeout
|
||||||
|
func New(shutdownTimeout time.Duration) *Lifecycle {
|
||||||
|
return &Lifecycle{
|
||||||
|
shutdownTimeout: shutdownTimeout,
|
||||||
|
services: make([]Service, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register adds a service to the lifecycle manager
|
||||||
|
func (l *Lifecycle) Register(service Service) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
l.services = append(l.services, service)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts all registered services
|
||||||
|
func (l *Lifecycle) Start() error {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
// Start all services
|
||||||
|
for _, svc := range l.services {
|
||||||
|
service := svc // Create a new variable for the closure
|
||||||
|
l.tomb.Go(func() error {
|
||||||
|
logger.Info("Starting service: ", service.Name())
|
||||||
|
if err := service.Start(); err != nil {
|
||||||
|
logger.Errorf("Service %s failed to start: %v", service.Name(), err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logger.Info("Service started: ", service.Name())
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait blocks until all services have stopped
|
||||||
|
func (l *Lifecycle) Wait() error {
|
||||||
|
return l.tomb.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown gracefully shuts down all services
|
||||||
|
func (l *Lifecycle) Shutdown() error {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
logger.Info("Initiating graceful shutdown...")
|
||||||
|
|
||||||
|
// Create a context with timeout
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), l.shutdownTimeout*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Notify all services to shut down
|
||||||
|
l.tomb.Kill(nil)
|
||||||
|
|
||||||
|
// Shutdown all services in reverse order
|
||||||
|
var errs error
|
||||||
|
for i := len(l.services) - 1; i >= 0; i-- {
|
||||||
|
svc := l.services[i]
|
||||||
|
logger.Infof("Shutting down service: %s", svc.Name())
|
||||||
|
if err := svc.Shutdown(ctx); err != nil {
|
||||||
|
logger.Errorf("Error shutting down %s: %v", svc.Name(), err)
|
||||||
|
errs = multierr.Append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all services to stop or timeout
|
||||||
|
errChan := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errChan <- l.tomb.Wait()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errChan:
|
||||||
|
if err != nil {
|
||||||
|
errs = multierr.Append(errs, err)
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
errs = multierr.Append(errs, ctx.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
if errs != nil {
|
||||||
|
logger.Errorf("Shutdown completed with errors: %v", errs)
|
||||||
|
} else {
|
||||||
|
logger.Info("Shutdown completed successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShutdownOnSignal shuts down the lifecycle when a signal is received
|
||||||
|
func (l *Lifecycle) ShutdownOnSignal(signals ...os.Signal) {
|
||||||
|
if len(signals) == 0 {
|
||||||
|
signals = []os.Signal{syscall.SIGINT, syscall.SIGTERM}
|
||||||
|
}
|
||||||
|
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, signals...)
|
||||||
|
|
||||||
|
l.tomb.Go(func() error {
|
||||||
|
select {
|
||||||
|
case sig := <-sigChan:
|
||||||
|
logger.Infof("Received signal: %v. Initiating shutdown...", sig)
|
||||||
|
return l.Shutdown()
|
||||||
|
case <-l.tomb.Dying():
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
236
internal/service/auth_service.go
Normal file
236
internal/service/auth_service.go
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm" // Added gorm import
|
||||||
|
"starter-kit/internal/domain/role"
|
||||||
|
"starter-kit/internal/domain/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthService xử lý các tác vụ liên quan đến xác thực
|
||||||
|
type AuthService interface {
|
||||||
|
Register(ctx context.Context, req RegisterRequest) (*user.User, error)
|
||||||
|
Login(ctx context.Context, username, password string) (string, string, error)
|
||||||
|
RefreshToken(refreshToken string) (string, string, error)
|
||||||
|
ValidateToken(tokenString string) (*Claims, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type authService struct {
|
||||||
|
userRepo user.Repository
|
||||||
|
roleRepo role.Repository
|
||||||
|
jwtSecret string
|
||||||
|
jwtExpiration time.Duration
|
||||||
|
refreshExpires int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claims định nghĩa các thông tin trong JWT token
|
||||||
|
type Claims struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthService tạo mới một AuthService
|
||||||
|
func NewAuthService(
|
||||||
|
userRepo user.Repository,
|
||||||
|
roleRepo role.Repository,
|
||||||
|
jwtSecret string,
|
||||||
|
jwtExpiration time.Duration,
|
||||||
|
) AuthService {
|
||||||
|
return &authService{
|
||||||
|
userRepo: userRepo,
|
||||||
|
roleRepo: roleRepo,
|
||||||
|
jwtSecret: jwtSecret,
|
||||||
|
jwtExpiration: jwtExpiration,
|
||||||
|
refreshExpires: 7 * 24 * 60, // 7 days in minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register đăng ký người dùng mới
|
||||||
|
func (s *authService) Register(ctx context.Context, req RegisterRequest) (*user.User, error) {
|
||||||
|
// Kiểm tra username đã tồn tại chưa
|
||||||
|
existingUser, err := s.userRepo.GetByUsername(ctx, req.Username)
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { // Chỉ coi là lỗi nếu không phải RecordNotFound
|
||||||
|
return nil, fmt.Errorf("error checking username: %w", err)
|
||||||
|
}
|
||||||
|
if existingUser != nil { // Nếu existingUser không nil, nghĩa là user đã tồn tại
|
||||||
|
return nil, errors.New("username already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kiểm tra email đã tồn tại chưa
|
||||||
|
existingEmail, err := s.userRepo.GetByEmail(ctx, req.Email)
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { // Chỉ coi là lỗi nếu không phải RecordNotFound
|
||||||
|
return nil, fmt.Errorf("error checking email: %v", err)
|
||||||
|
}
|
||||||
|
if existingEmail != nil {
|
||||||
|
return nil, errors.New("email already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mã hóa mật khẩu
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error hashing password: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo user mới
|
||||||
|
newUser := &user.User{
|
||||||
|
Username: req.Username,
|
||||||
|
Email: req.Email,
|
||||||
|
PasswordHash: string(hashedPassword),
|
||||||
|
FullName: req.FullName,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lưu user vào database
|
||||||
|
if err := s.userRepo.Create(ctx, newUser); err != nil {
|
||||||
|
return nil, fmt.Errorf("error creating user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thêm role mặc định là 'user' cho người dùng mới
|
||||||
|
userRole, err := s.roleRepo.GetByName(ctx, role.User)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error getting user role: %v", err)
|
||||||
|
}
|
||||||
|
if userRole == nil {
|
||||||
|
return nil, errors.New("default user role not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.userRepo.AddRole(ctx, newUser.ID, userRole.ID); err != nil {
|
||||||
|
return nil, fmt.Errorf("error adding role to user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lấy lại thông tin user với đầy đủ roles
|
||||||
|
createdUser, err := s.userRepo.GetByID(ctx, newUser.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error getting created user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdUser, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login xác thực đăng nhập và trả về token
|
||||||
|
func (s *authService) Login(ctx context.Context, username, password string) (string, string, error) {
|
||||||
|
// Lấy thông tin user
|
||||||
|
user, err := s.userRepo.GetByUsername(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
if user == nil || !user.IsActive {
|
||||||
|
return "", "", errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kiểm tra mật khẩu
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||||
|
return "", "", errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo access token
|
||||||
|
accessToken, err := s.generateToken(user)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("error generating token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo refresh token
|
||||||
|
tokenBytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(tokenBytes); err != nil {
|
||||||
|
return "", "", fmt.Errorf("error generating refresh token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lưu refresh token vào database (trong thực tế nên lưu vào Redis hoặc database)
|
||||||
|
// Ở đây chỉ minh họa, nên implement thật kỹ hơn
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write(tokenBytes)
|
||||||
|
tokenID := base64.URLEncoding.EncodeToString(h.Sum(nil))
|
||||||
|
|
||||||
|
// TODO: Lưu refresh token vào database với userID và tokenID
|
||||||
|
_ = tokenID
|
||||||
|
|
||||||
|
// Cập nhật thời gian đăng nhập cuối cùng
|
||||||
|
if err := s.userRepo.UpdateLastLogin(ctx, user.ID); err != nil {
|
||||||
|
// Log lỗi nhưng không ảnh hưởng đến quá trình đăng nhập
|
||||||
|
fmt.Printf("Error updating last login: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return accessToken, string(tokenBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken làm mới access token
|
||||||
|
func (s *authService) RefreshToken(refreshToken string) (string, string, error) {
|
||||||
|
// TODO: Kiểm tra refresh token trong database
|
||||||
|
// Nếu hợp lệ, tạo access token mới và trả về
|
||||||
|
return "", "", errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateToken xác thực và trả về thông tin từ token
|
||||||
|
func (s *authService) ValidateToken(tokenString string) (*Claims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
// Kiểm tra signing method
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
|
}
|
||||||
|
return []byte(s.jwtSecret), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateToken tạo JWT token cho user
|
||||||
|
func (s *authService) generateToken(user *user.User) (string, error) {
|
||||||
|
// Lấy danh sách roles
|
||||||
|
roles := make([]string, len(user.Roles))
|
||||||
|
for i, r := range user.Roles {
|
||||||
|
roles[i] = r.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo claims
|
||||||
|
expirationTime := time.Now().Add(s.jwtExpiration)
|
||||||
|
claims := &Claims{
|
||||||
|
UserID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Roles: roles,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||||
|
Issuer: "ulflow-starter-kit",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo token
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
|
||||||
|
// Ký token và trả về
|
||||||
|
tokenString, err := token.SignedString([]byte(s.jwtSecret))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenString, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRequest định dạng dữ liệu đăng ký
|
||||||
|
type RegisterRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
}
|
||||||
339
internal/service/auth_service_test.go
Normal file
339
internal/service/auth_service_test.go
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
package service_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"starter-kit/internal/domain/role"
|
||||||
|
"starter-kit/internal/domain/user"
|
||||||
|
"starter-kit/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockUserRepo là mock cho user.Repository
|
||||||
|
type MockUserRepo struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepo) Create(ctx context.Context, user *user.User) error {
|
||||||
|
args := m.Called(ctx, user)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepo) GetByID(ctx context.Context, id string) (*user.User, error) {
|
||||||
|
args := m.Called(ctx, id)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).(*user.User), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepo) GetByUsername(ctx context.Context, username string) (*user.User, error) {
|
||||||
|
args := m.Called(ctx, username)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).(*user.User), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepo) GetByEmail(ctx context.Context, email string) (*user.User, error) {
|
||||||
|
args := m.Called(ctx, email)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).(*user.User), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepo) UpdateLastLogin(ctx context.Context, id string) error {
|
||||||
|
args := m.Called(ctx, id)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepo) AddRole(ctx context.Context, userID string, roleID int) error {
|
||||||
|
args := m.Called(ctx, userID, roleID)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepo) RemoveRole(ctx context.Context, userID string, roleID int) error {
|
||||||
|
args := m.Called(ctx, userID, roleID)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepo) HasRole(ctx context.Context, userID string, roleID int) (bool, error) {
|
||||||
|
args := m.Called(ctx, userID, roleID)
|
||||||
|
return args.Bool(0), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepo) Update(ctx context.Context, user *user.User) error {
|
||||||
|
args := m.Called(ctx, user)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepo) Delete(ctx context.Context, id string) error {
|
||||||
|
args := m.Called(ctx, id)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockRoleRepo là mock cho role.Repository
|
||||||
|
type MockRoleRepo struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRoleRepo) GetByName(ctx context.Context, name string) (*role.Role, error) {
|
||||||
|
args := m.Called(ctx, name)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).(*role.Role), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRoleRepo) Create(ctx context.Context, role *role.Role) error {
|
||||||
|
args := m.Called(ctx, role)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRoleRepo) GetByID(ctx context.Context, id int) (*role.Role, error) {
|
||||||
|
args := m.Called(ctx, id)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).(*role.Role), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRoleRepo) List(ctx context.Context) ([]*role.Role, error) {
|
||||||
|
args := m.Called(ctx)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).([]*role.Role), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRoleRepo) Update(ctx context.Context, role *role.Role) error {
|
||||||
|
args := m.Called(ctx, role)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRoleRepo) Delete(ctx context.Context, id int) error {
|
||||||
|
args := m.Called(ctx, id)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthService_Register(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setup func(*MockUserRepo, *MockRoleRepo)
|
||||||
|
req service.RegisterRequest
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "successful registration",
|
||||||
|
setup: func(mu *MockUserRepo, mr *MockRoleRepo) {
|
||||||
|
// Mock GetByUsername - user not exists
|
||||||
|
mu.On("GetByUsername", mock.Anything, "testuser").
|
||||||
|
Return((*user.User)(nil), nil)
|
||||||
|
|
||||||
|
// Mock GetByEmail - email not exists
|
||||||
|
mu.On("GetByEmail", mock.Anything, "test@example.com").
|
||||||
|
Return((*user.User)(nil), nil)
|
||||||
|
|
||||||
|
// Mock GetByName - role exists
|
||||||
|
mr.On("GetByName", mock.Anything, role.User).
|
||||||
|
Return(&role.Role{ID: 1, Name: role.User}, nil)
|
||||||
|
|
||||||
|
// Mock AddRole
|
||||||
|
mu.On("AddRole", mock.Anything, mock.Anything, mock.Anything).
|
||||||
|
Return(nil)
|
||||||
|
|
||||||
|
// Mock Create - success
|
||||||
|
mu.On("Create", mock.Anything, mock.AnythingOfType("*user.User")).
|
||||||
|
Return(nil)
|
||||||
|
|
||||||
|
|
||||||
|
// Mock GetByID - return created user
|
||||||
|
mu.On("GetByID", mock.Anything, mock.Anything).
|
||||||
|
Return(&user.User{
|
||||||
|
ID: "123",
|
||||||
|
Username: "testuser",
|
||||||
|
Email: "test@example.com",
|
||||||
|
FullName: "Test User",
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
},
|
||||||
|
req: service.RegisterRequest{
|
||||||
|
Username: "testuser",
|
||||||
|
Email: "test@example.com",
|
||||||
|
Password: "password123",
|
||||||
|
FullName: "Test User",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Setup mocks
|
||||||
|
mockUserRepo := new(MockUserRepo)
|
||||||
|
mockRoleRepo := new(MockRoleRepo)
|
||||||
|
tt.setup(mockUserRepo, mockRoleRepo)
|
||||||
|
|
||||||
|
// Create service with mocks
|
||||||
|
svc := service.NewAuthService(
|
||||||
|
mockUserRepo,
|
||||||
|
mockRoleRepo,
|
||||||
|
"test-secret",
|
||||||
|
time.Hour,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Call method
|
||||||
|
_, err := svc.Register(context.Background(), tt.req)
|
||||||
|
|
||||||
|
// Assertions
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all expectations were met
|
||||||
|
mockUserRepo.AssertExpectations(t)
|
||||||
|
mockRoleRepo.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthService_Login(t *testing.T) {
|
||||||
|
// Create a test user with hashed password
|
||||||
|
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
|
||||||
|
testUser := &user.User{
|
||||||
|
ID: "123",
|
||||||
|
Username: "testuser",
|
||||||
|
Email: "test@example.com",
|
||||||
|
PasswordHash: string(hashedPassword),
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setup func(*MockUserRepo)
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "successful login",
|
||||||
|
setup: func(mu *MockUserRepo) {
|
||||||
|
// Mock GetByUsername - user exists
|
||||||
|
mu.On("GetByUsername", mock.Anything, "testuser").
|
||||||
|
Return(testUser, nil)
|
||||||
|
|
||||||
|
// Mock UpdateLastLogin
|
||||||
|
mu.On("UpdateLastLogin", mock.Anything, "123").
|
||||||
|
Return(nil)
|
||||||
|
},
|
||||||
|
username: "testuser",
|
||||||
|
password: "password123",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid password",
|
||||||
|
setup: func(mu *MockUserRepo) {
|
||||||
|
// Mock GetByUsername - user exists
|
||||||
|
mu.On("GetByUsername", mock.Anything, "testuser").
|
||||||
|
Return(testUser, nil)
|
||||||
|
},
|
||||||
|
username: "testuser",
|
||||||
|
password: "wrongpassword",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Setup mocks
|
||||||
|
mockUserRepo := new(MockUserRepo)
|
||||||
|
tt.setup(mockUserRepo)
|
||||||
|
|
||||||
|
// Create service with mocks
|
||||||
|
svc := service.NewAuthService(
|
||||||
|
mockUserRepo,
|
||||||
|
nil, // Role repo not needed for login
|
||||||
|
"test-secret",
|
||||||
|
time.Hour,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
// Call method
|
||||||
|
_, _, err := svc.Login(context.Background(), tt.username, tt.password)
|
||||||
|
|
||||||
|
// Assertions
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all expectations were met
|
||||||
|
mockUserRepo.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthService_ValidateToken(t *testing.T) {
|
||||||
|
// Create a test service
|
||||||
|
svc := service.NewAuthService(
|
||||||
|
nil, // Repos not needed for this test
|
||||||
|
nil,
|
||||||
|
"test-secret",
|
||||||
|
time.Hour,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create a valid token
|
||||||
|
claims := &service.Claims{
|
||||||
|
UserID: "123",
|
||||||
|
Username: "testuser",
|
||||||
|
Roles: []string{"user"},
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
tokenString, _ := token.SignedString([]byte("test-secret"))
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
wantClaims *service.Claims
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid token",
|
||||||
|
token: tokenString,
|
||||||
|
wantClaims: claims,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid signature",
|
||||||
|
token: tokenString[:len(tokenString)-2] + "xx", // Corrupt the signature
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
claims, err := svc.ValidateToken(tt.token)
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.wantClaims.UserID, claims.UserID)
|
||||||
|
assert.Equal(t, tt.wantClaims.Username, claims.Username)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
8
internal/transport/http/dto/error_response.go
Normal file
8
internal/transport/http/dto/error_response.go
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// ErrorResponse định dạng phản hồi lỗi
|
||||||
|
// @Description Định dạng phản hồi lỗi
|
||||||
|
// @Description Error response format
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Error string `json:"error" example:"error message"`
|
||||||
|
}
|
||||||
70
internal/transport/http/dto/user_dto.go
Normal file
70
internal/transport/http/dto/user_dto.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"starter-kit/internal/domain/role"
|
||||||
|
"starter-kit/internal/domain/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterRequest định dạng dữ liệu đăng ký người dùng mới
|
||||||
|
type RegisterRequest struct {
|
||||||
|
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
Password string `json:"password" binding:"required,min=8"`
|
||||||
|
FullName string `json:"full_name" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginRequest định dạng dữ liệu đăng nhập
|
||||||
|
type LoginRequest struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthResponse định dạng phản hồi xác thực
|
||||||
|
type AuthResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserResponse định dạng phản hồi thông tin người dùng
|
||||||
|
type UserResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
AvatarURL string `json:"avatar_url,omitempty"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
Roles []role.Role `json:"roles,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToUserResponse chuyển đổi từ User sang UserResponse
|
||||||
|
func ToUserResponse(userObj interface{}) UserResponse {
|
||||||
|
switch u := userObj.(type) {
|
||||||
|
case *user.User:
|
||||||
|
// Handle actual domain User model
|
||||||
|
roles := make([]role.Role, 0)
|
||||||
|
if u.Roles != nil {
|
||||||
|
for _, r := range u.Roles {
|
||||||
|
roles = append(roles, *r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserResponse{
|
||||||
|
ID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
Email: u.Email,
|
||||||
|
FullName: u.FullName,
|
||||||
|
AvatarURL: u.AvatarURL,
|
||||||
|
IsActive: u.IsActive,
|
||||||
|
Roles: roles,
|
||||||
|
CreatedAt: u.CreatedAt,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// If we can't handle this type, return an empty response
|
||||||
|
return UserResponse{}
|
||||||
|
}
|
||||||
|
}
|
||||||
149
internal/transport/http/handler/auth_handler.go
Normal file
149
internal/transport/http/handler/auth_handler.go
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"starter-kit/internal/service"
|
||||||
|
"starter-kit/internal/transport/http/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthHandler struct {
|
||||||
|
authSvc service.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthHandler tạo mới AuthHandler
|
||||||
|
func NewAuthHandler(authSvc service.AuthService) *AuthHandler {
|
||||||
|
return &AuthHandler{
|
||||||
|
authSvc: authSvc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register xử lý đăng ký người dùng mới
|
||||||
|
// @Summary Đăng ký tài khoản mới
|
||||||
|
// @Description Tạo tài khoản người dùng mới với thông tin cơ bản
|
||||||
|
// @Tags Authentication
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body dto.RegisterRequest true "Thông tin đăng ký"
|
||||||
|
// @Success 201 {object} dto.UserResponse
|
||||||
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
|
// @Failure 409 {object} dto.ErrorResponse
|
||||||
|
// @Failure 500 {object} dto.ErrorResponse
|
||||||
|
// @Router /api/v1/auth/register [post]
|
||||||
|
func (h *AuthHandler) Register(c *gin.Context) {
|
||||||
|
var req dto.RegisterRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Error: "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gọi service để đăng ký
|
||||||
|
user, err := h.authSvc.Register(c.Request.Context(), service.RegisterRequest(req))
|
||||||
|
if err != nil {
|
||||||
|
// Xử lý lỗi trả về
|
||||||
|
if strings.Contains(err.Error(), "already exists") {
|
||||||
|
c.JSON(http.StatusConflict, dto.ErrorResponse{Error: err.Error()})
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Error: "Internal server error"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chuyển đổi sang DTO và trả về
|
||||||
|
userResponse := dto.ToUserResponse(user)
|
||||||
|
c.JSON(http.StatusCreated, userResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login xử lý đăng nhập
|
||||||
|
// @Summary Đăng nhập
|
||||||
|
// @Description Đăng nhập bằng username và password
|
||||||
|
// @Tags Authentication
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body dto.LoginRequest true "Thông tin đăng nhập"
|
||||||
|
// @Success 200 {object} dto.AuthResponse
|
||||||
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
|
// @Failure 401 {object} dto.ErrorResponse
|
||||||
|
// @Failure 500 {object} dto.ErrorResponse
|
||||||
|
// @Router /api/v1/auth/login [post]
|
||||||
|
func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
|
var req dto.LoginRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Error: "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gọi service để đăng nhập
|
||||||
|
accessToken, refreshToken, err := h.authSvc.Login(c.Request.Context(), req.Username, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, dto.ErrorResponse{Error: "Invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo response
|
||||||
|
expiresAt := time.Now().Add(24 * time.Hour) // Thời gian hết hạn mặc định
|
||||||
|
response := dto.AuthResponse{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
TokenType: "Bearer",
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken làm mới access token
|
||||||
|
// @Summary Làm mới access token
|
||||||
|
// @Description Làm mới access token bằng refresh token
|
||||||
|
// @Tags Authentication
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param refresh_token body string true "Refresh token"
|
||||||
|
// @Success 200 {object} dto.AuthResponse
|
||||||
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
|
// @Failure 401 {object} dto.ErrorResponse
|
||||||
|
// @Router /api/v1/auth/refresh [post]
|
||||||
|
func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||||||
|
// Lấy refresh token từ body
|
||||||
|
var req struct {
|
||||||
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Error: "Refresh token is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gọi service để làm mới token
|
||||||
|
accessToken, refreshToken, err := h.authSvc.RefreshToken(req.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, dto.ErrorResponse{Error: "Invalid refresh token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo response
|
||||||
|
expiresAt := time.Now().Add(24 * time.Hour) // Thời gian hết hạn mặc định
|
||||||
|
response := dto.AuthResponse{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
TokenType: "Bearer",
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout xử lý đăng xuất
|
||||||
|
// @Summary Đăng xuất
|
||||||
|
// @Description Đăng xuất và vô hiệu hóa refresh token
|
||||||
|
// @Tags Authentication
|
||||||
|
// @Security Bearer
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/v1/auth/logout [post]
|
||||||
|
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||||
|
// TODO: Vô hiệu hóa refresh token trong database
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
221
internal/transport/http/handler/auth_register_test.go
Normal file
221
internal/transport/http/handler/auth_register_test.go
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"starter-kit/internal/adapter/persistence"
|
||||||
|
"starter-kit/internal/domain/role"
|
||||||
|
"starter-kit/internal/domain/user"
|
||||||
|
"starter-kit/internal/service"
|
||||||
|
"starter-kit/internal/transport/http/dto"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mock user repository với khả năng hook
|
||||||
|
type mockUserRepo struct {
|
||||||
|
user.Repository // nhúng interface để implement tự động
|
||||||
|
CreateFunc func(ctx context.Context, u *user.User) error
|
||||||
|
GetByIDFunc func(ctx context.Context, id string) (*user.User, error)
|
||||||
|
AddRoleFunc func(ctx context.Context, userID string, roleID int) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockUserRepo) Create(ctx context.Context, u *user.User) error {
|
||||||
|
if m.CreateFunc != nil {
|
||||||
|
return m.CreateFunc(ctx, u)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockUserRepo) GetByID(ctx context.Context, id string) (*user.User, error) {
|
||||||
|
if m.GetByIDFunc != nil {
|
||||||
|
return m.GetByIDFunc(ctx, id)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockUserRepo) AddRole(ctx context.Context, userID string, roleID int) error {
|
||||||
|
if m.AddRoleFunc != nil {
|
||||||
|
return m.AddRoleFunc(ctx, userID, roleID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterHandler(t *testing.T) {
|
||||||
|
// Thiết lập
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// UUID cố định cho bài test
|
||||||
|
testUserID := "123e4567-e89b-12d3-a456-426614174000"
|
||||||
|
|
||||||
|
// Tạo mock database
|
||||||
|
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Không thể tạo mock database: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
|
// Kết nối GORM
|
||||||
|
gormDB, err := gorm.Open(mysql.New(mysql.Config{
|
||||||
|
Conn: db,
|
||||||
|
SkipInitializeWithVersion: true,
|
||||||
|
}), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Không thể kết nối GORM: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo repositories thật sẽ kết nối với mock DB
|
||||||
|
realUserRepo := persistence.NewUserRepository(gormDB)
|
||||||
|
roleRepo := persistence.NewRoleRepository(gormDB)
|
||||||
|
|
||||||
|
// Tạo mock repository với đầy đủ các phương thức cần thiết
|
||||||
|
mockedUserRepo := &mockUserRepo{
|
||||||
|
Repository: realUserRepo, // delegate các phương thức còn lại
|
||||||
|
CreateFunc: func(ctx context.Context, u *user.User) error {
|
||||||
|
// Chú ý: Trong thực tế, ID sẽ được tạo bởi DB (uuid_generate_v4())
|
||||||
|
// Nhưng vì đây là test, chúng ta cần giả lập việc DB thiết lập ID sau khi INSERT
|
||||||
|
// Gọi repository thật để thực thi SQL
|
||||||
|
err := realUserRepo.Create(ctx, u)
|
||||||
|
// Gán ID cố định sau khi tạo, giả lập việc DB tạo và trả về ID
|
||||||
|
u.ID = testUserID
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
GetByIDFunc: func(ctx context.Context, id string) (*user.User, error) {
|
||||||
|
// Tạo user đủ thông tin với role đã preload
|
||||||
|
userRole := &role.Role{ID: 1, Name: "user", Description: "Basic user role"}
|
||||||
|
u := &user.User{
|
||||||
|
ID: testUserID,
|
||||||
|
Username: "testuser",
|
||||||
|
Email: "test@example.com",
|
||||||
|
FullName: "Test User",
|
||||||
|
AvatarURL: "",
|
||||||
|
IsActive: true,
|
||||||
|
Roles: []*role.Role{userRole}, // Gán role đã preload
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
},
|
||||||
|
AddRoleFunc: func(ctx context.Context, userID string, roleID int) error {
|
||||||
|
// Kiểm tra đảm bảo ID phù hợp
|
||||||
|
if userID != testUserID {
|
||||||
|
return fmt.Errorf("expected user ID %s but got %s", testUserID, userID)
|
||||||
|
}
|
||||||
|
// Khi chúng ta gọi AddRole của repo thật, nó sẽ thực thi câu lệnh SQL
|
||||||
|
return realUserRepo.AddRole(ctx, userID, roleID)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo service với mock userRepo
|
||||||
|
jwtSecret := "test-secret-key"
|
||||||
|
authSvc := service.NewAuthService(mockedUserRepo, roleRepo, jwtSecret, time.Duration(15)*time.Minute)
|
||||||
|
|
||||||
|
// Tạo handler
|
||||||
|
authHandler := NewAuthHandler(authSvc)
|
||||||
|
|
||||||
|
// Tạo router
|
||||||
|
r := gin.Default()
|
||||||
|
r.POST("/api/v1/auth/register", authHandler.Register)
|
||||||
|
|
||||||
|
// Dữ liệu đăng ký
|
||||||
|
registerData := dto.RegisterRequest{
|
||||||
|
Username: "testuser",
|
||||||
|
Email: "test@example.com",
|
||||||
|
Password: "password123",
|
||||||
|
FullName: "Test User",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chuyển đổi dữ liệu thành JSON
|
||||||
|
jsonData, err := json.Marshal(registerData)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lỗi chuyển đổi JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Đăng ký tài khoản mới thành công", func(t *testing.T) {
|
||||||
|
// Setup các mong đợi SQL match chính xác với GORM theo logs và UserRepository implementation
|
||||||
|
|
||||||
|
// 1. Kiểm tra xem username đã tồn tại chưa (userRepo.GetByUsername)
|
||||||
|
mock.ExpectQuery("SELECT \\* FROM `users` WHERE username = \\? ORDER BY `users`\\.`id` LIMIT \\?").
|
||||||
|
WithArgs("testuser", 1).
|
||||||
|
WillReturnError(gorm.ErrRecordNotFound) // Username 'testuser' chưa tồn tại
|
||||||
|
|
||||||
|
// 2. Kiểm tra xem email đã tồn tại chưa (userRepo.GetByEmail)
|
||||||
|
mock.ExpectQuery("SELECT \\* FROM `users` WHERE email = \\? ORDER BY `users`\\.`id` LIMIT \\?").
|
||||||
|
WithArgs("test@example.com", 1).
|
||||||
|
WillReturnError(gorm.ErrRecordNotFound) // Email 'test@example.com' chưa tồn tại
|
||||||
|
|
||||||
|
// --- Sequence of operations after successful username/email checks and password hashing ---
|
||||||
|
|
||||||
|
// 3. Transaction for userRepo.Create (Implicit transaction by GORM)
|
||||||
|
mock.ExpectBegin()
|
||||||
|
// 4. Tạo user mới (userRepo.Create)
|
||||||
|
// Khi không đặt trước ID, GORM không đưa ID vào SQL, để DB tạo UUID tự động
|
||||||
|
mock.ExpectExec("^INSERT INTO `users` \\(`username`,`email`,`password_hash`,`full_name`,`avatar_url`,`is_active`,`last_login_at`,`created_at`,`updated_at`,`deleted_at`\\) VALUES \\(\\?,\\?,\\?,\\?,\\?,\\?,\\?,\\?,\\?,\\?\\)").
|
||||||
|
WithArgs(
|
||||||
|
"testuser", // username
|
||||||
|
"test@example.com", // email
|
||||||
|
sqlmock.AnyArg(), // password_hash
|
||||||
|
"Test User", // full_name
|
||||||
|
"", // avatar_url
|
||||||
|
true, // is_active
|
||||||
|
sqlmock.AnyArg(), // last_login_at
|
||||||
|
sqlmock.AnyArg(), // created_at
|
||||||
|
sqlmock.AnyArg(), // updated_at
|
||||||
|
sqlmock.AnyArg(), // deleted_at
|
||||||
|
).
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1)) // UUID không có sequence ID, chỉ cần 1 row affected
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
// 5. Lấy role mặc định 'user' (roleRepo.GetByName)
|
||||||
|
mock.ExpectQuery("SELECT \\* FROM `roles` WHERE name = \\? ORDER BY `roles`\\.`id` LIMIT \\?").
|
||||||
|
WithArgs("user", 1).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "description", "created_at", "updated_at", "deleted_at"}).
|
||||||
|
AddRow(1, "user", "Basic user role", time.Now(), time.Now(), nil))
|
||||||
|
|
||||||
|
// 6. Thêm role cho user (userRepo.AddRole -> user_roles table)
|
||||||
|
// GORM's Create for user_roles có thể dùng 'INSERT ... ON CONFLICT'
|
||||||
|
mock.ExpectExec("INSERT INTO `user_roles` \\(`user_id`, `role_id`\\) VALUES \\(\\?\\, \\?\\)").
|
||||||
|
WithArgs(testUserID, 1). // user_id (UUID string), role_id (int)
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1)) // Thêm thành công 1 row
|
||||||
|
|
||||||
|
// Chú ý: Vì chúng ta đã override mockUserRepo.GetByID và mockUserRepo.AddRole
|
||||||
|
// nên không cần mock SQL cho các query lấy thông tin user sau khi tạo
|
||||||
|
// mockUserRepo.GetByID sẽ trả về user đã có role được preload
|
||||||
|
|
||||||
|
// Tạo request
|
||||||
|
req, _ := http.NewRequest("POST", "/api/v1/auth/register", bytes.NewBuffer(jsonData))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Thực thi request
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// Kiểm tra kết quả
|
||||||
|
assert.Equal(t, http.StatusCreated, w.Code, "Status code phải là 201")
|
||||||
|
|
||||||
|
// Parse JSON response
|
||||||
|
var response dto.UserResponse
|
||||||
|
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||||
|
assert.NoError(t, err, "Parse JSON không có lỗi")
|
||||||
|
|
||||||
|
// Kiểm tra thông tin phản hồi
|
||||||
|
assert.Equal(t, registerData.Username, response.Username, "Username phải khớp")
|
||||||
|
assert.Equal(t, registerData.Email, response.Email, "Email phải khớp")
|
||||||
|
assert.Equal(t, registerData.FullName, response.FullName, "FullName phải khớp")
|
||||||
|
assert.NotEmpty(t, response.ID, "ID không được rỗng")
|
||||||
|
|
||||||
|
// Kiểm tra nếu có SQL expectations nào chưa được đáp ứng
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Errorf("Các expectations chưa được đáp ứng: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
70
internal/transport/http/handler/health_handler.go
Normal file
70
internal/transport/http/handler/health_handler.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"starter-kit/internal/helper/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HealthHandler xử lý các endpoint liên quan đến sức khỏe hệ thống
|
||||||
|
type HealthHandler struct {
|
||||||
|
config *config.Config
|
||||||
|
startTime time.Time
|
||||||
|
appVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHealthHandler tạo một handler mới cho health endpoints
|
||||||
|
func NewHealthHandler(cfg *config.Config) *HealthHandler {
|
||||||
|
return &HealthHandler{
|
||||||
|
config: cfg,
|
||||||
|
startTime: time.Now(),
|
||||||
|
appVersion: cfg.App.Version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheck trả về trạng thái sức khỏe của hệ thống
|
||||||
|
// @Summary Kiểm tra trạng thái của hệ thống
|
||||||
|
// @Description Trả về thông tin về trạng thái của ứng dụng và các thành phần
|
||||||
|
// @Tags Health
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} map[string]interface{}
|
||||||
|
// @Router /health [get]
|
||||||
|
func (h *HealthHandler) HealthCheck(c *gin.Context) {
|
||||||
|
uptime := time.Since(h.startTime).String()
|
||||||
|
|
||||||
|
// Tạo response
|
||||||
|
resp := gin.H{
|
||||||
|
"status": "ok",
|
||||||
|
"app": gin.H{
|
||||||
|
"name": h.config.App.Name,
|
||||||
|
"version": h.appVersion,
|
||||||
|
"env": h.config.App.Environment,
|
||||||
|
},
|
||||||
|
"uptime": uptime,
|
||||||
|
// Trong môi trường thực tế, thêm kiểm tra trạng thái của database và các dịch vụ bên ngoài
|
||||||
|
"components": gin.H{
|
||||||
|
"database": "ok", // Giả định - trong thực tế sẽ kiểm tra kết nối
|
||||||
|
"cache": "ok", // Giả định
|
||||||
|
},
|
||||||
|
"timestamp": time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping endpoint đơn giản để kiểm tra server đang chạy
|
||||||
|
// @Summary Ping server
|
||||||
|
// @Description Endpoint đơn giản để kiểm tra server đang hoạt động
|
||||||
|
// @Tags Health
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Router /ping [get]
|
||||||
|
func (h *HealthHandler) Ping(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": "ok",
|
||||||
|
"message": "pong",
|
||||||
|
"timestamp": time.Now().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
308
internal/transport/http/handler/health_handler_test.go
Normal file
308
internal/transport/http/handler/health_handler_test.go
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
|
||||||
|
"starter-kit/internal/helper/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockConfig is a mock of config.Config
|
||||||
|
type MockConfig struct {
|
||||||
|
mock.Mock
|
||||||
|
App config.AppConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockConfig) GetAppConfig() *config.AppConfig {
|
||||||
|
args := m.Called()
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return args.Get(0).(*config.AppConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewHealthHandler(t *testing.T) {
|
||||||
|
t.Run("creates new health handler with config", func(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
App: config.AppConfig{
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Environment: "test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewHealthHandler(cfg)
|
||||||
|
|
||||||
|
assert.NotNil(t, handler)
|
||||||
|
assert.Equal(t, cfg.App.Version, handler.appVersion)
|
||||||
|
assert.False(t, handler.startTime.IsZero())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthCheck(t *testing.T) {
|
||||||
|
// Setup test cases
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setupMock func(*MockConfig)
|
||||||
|
expectedCode int
|
||||||
|
expectedKeys []string
|
||||||
|
checkUptime bool
|
||||||
|
checkAppInfo bool
|
||||||
|
expectedValues map[string]interface{}
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "successful health check",
|
||||||
|
setupMock: func(mc *MockConfig) {
|
||||||
|
mc.App = config.AppConfig{
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Environment: "test",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusOK,
|
||||||
|
expectedKeys: []string{"status", "app", "uptime", "components", "timestamp"},
|
||||||
|
expectedValues: map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"app": map[string]interface{}{
|
||||||
|
"name": "test-app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"env": "test",
|
||||||
|
},
|
||||||
|
"components": map[string]interface{}{
|
||||||
|
"database": "ok",
|
||||||
|
"cache": "ok",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
checkUptime: true,
|
||||||
|
checkAppInfo: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "health check with empty config",
|
||||||
|
setupMock: func(mc *MockConfig) {
|
||||||
|
mc.App = config.AppConfig{}
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusOK,
|
||||||
|
expectedValues: map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"app": map[string]interface{}{
|
||||||
|
"name": "",
|
||||||
|
"version": "",
|
||||||
|
"env": "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Setup mock config
|
||||||
|
mockCfg := new(MockConfig)
|
||||||
|
tt.setupMock(mockCfg)
|
||||||
|
|
||||||
|
// Setup mock expectations
|
||||||
|
mockCfg.On("GetAppConfig").Return(&mockCfg.App)
|
||||||
|
|
||||||
|
|
||||||
|
// Create handler with mock config
|
||||||
|
handler := NewHealthHandler(&config.Config{
|
||||||
|
App: *mockCfg.GetAppConfig(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// Create a new request
|
||||||
|
req := httptest.NewRequest("GET", "/health", nil)
|
||||||
|
|
||||||
|
// Create a response recorder
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Create a new router and register the handler
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/health", handler.HealthCheck)
|
||||||
|
|
||||||
|
// Serve the request
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
|
||||||
|
// Assert the status code
|
||||||
|
assert.Equal(t, tt.expectedCode, w.Code)
|
||||||
|
|
||||||
|
// Parse the response body
|
||||||
|
var response map[string]interface{}
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response), "Failed to parse response body")
|
||||||
|
|
||||||
|
// Check expected keys exist
|
||||||
|
for _, key := range tt.expectedKeys {
|
||||||
|
_, exists := response[key]
|
||||||
|
assert.True(t, exists, "Response should contain key: %s", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expected values
|
||||||
|
for key, expectedValue := range tt.expectedValues {
|
||||||
|
switch v := expectedValue.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
actual, exists := response[key].(map[string]interface{})
|
||||||
|
require.True(t, exists, "Expected %s to be a map", key)
|
||||||
|
for subKey, subValue := range v {
|
||||||
|
assert.Equal(t, subValue, actual[subKey], "Mismatch for %s.%s", key, subKey)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
assert.Equal(t, expectedValue, response[key], "Mismatch for %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check uptime if needed
|
||||||
|
if tt.checkUptime {
|
||||||
|
_, exists := response["uptime"]
|
||||||
|
assert.True(t, exists, "Response should contain uptime")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check app info if needed
|
||||||
|
if tt.checkAppInfo {
|
||||||
|
appInfo, ok := response["app"].(map[string]interface{})
|
||||||
|
assert.True(t, ok, "app should be a map")
|
||||||
|
assert.Equal(t, "test-app", appInfo["name"])
|
||||||
|
assert.Equal(t, "1.0.0", appInfo["version"])
|
||||||
|
assert.Equal(t, "test", appInfo["env"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check uptime is a valid duration string
|
||||||
|
if tt.checkUptime {
|
||||||
|
uptime, ok := response["uptime"].(string)
|
||||||
|
assert.True(t, ok, "uptime should be a string")
|
||||||
|
_, err := time.ParseDuration(uptime)
|
||||||
|
assert.NoError(t, err, "uptime should be a valid duration string")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check components
|
||||||
|
components, ok := response["components"].(map[string]interface{})
|
||||||
|
assert.True(t, ok, "components should be a map")
|
||||||
|
assert.Equal(t, "ok", components["database"])
|
||||||
|
assert.Equal(t, "ok", components["cache"])
|
||||||
|
|
||||||
|
// Check timestamp format
|
||||||
|
timestamp, ok := response["timestamp"].(string)
|
||||||
|
assert.True(t, ok, "timestamp should be a string")
|
||||||
|
_, err := time.Parse(time.RFC3339, timestamp)
|
||||||
|
assert.NoError(t, err, "timestamp should be in RFC3339 format")
|
||||||
|
|
||||||
|
// Assert that all expectations were met
|
||||||
|
mockCfg.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPing(t *testing.T) {
|
||||||
|
// Setup test cases
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setupMock func(*MockConfig)
|
||||||
|
expectedCode int
|
||||||
|
expectedValues map[string]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "successful ping",
|
||||||
|
setupMock: func(mc *MockConfig) {
|
||||||
|
mc.App = config.AppConfig{
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Environment: "test",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusOK,
|
||||||
|
expectedValues: map[string]string{
|
||||||
|
"status": "ok",
|
||||||
|
"message": "pong",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Setup mock config
|
||||||
|
mockCfg := new(MockConfig)
|
||||||
|
tt.setupMock(mockCfg)
|
||||||
|
|
||||||
|
// Setup mock expectations
|
||||||
|
mockCfg.On("GetAppConfig").Return(&mockCfg.App)
|
||||||
|
|
||||||
|
|
||||||
|
// Create handler with mock config
|
||||||
|
handler := NewHealthHandler(&config.Config{
|
||||||
|
App: *mockCfg.GetAppConfig(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create a new request
|
||||||
|
req := httptest.NewRequest("GET", "/ping", nil)
|
||||||
|
|
||||||
|
// Create a response recorder
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Create a new router and register the handler
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/ping", handler.Ping)
|
||||||
|
|
||||||
|
// Serve the request
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
|
||||||
|
// Assert the status code
|
||||||
|
assert.Equal(t, tt.expectedCode, w.Code)
|
||||||
|
|
||||||
|
// Parse the response body
|
||||||
|
var response map[string]interface{}
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response), "Failed to parse response body")
|
||||||
|
|
||||||
|
// Check expected values
|
||||||
|
for key, expectedValue := range tt.expectedValues {
|
||||||
|
actual, exists := response[key]
|
||||||
|
require.True(t, exists, "Expected key %s not found in response", key)
|
||||||
|
assert.Equal(t, expectedValue, actual, "Mismatch for key %s", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check timestamp is in the correct format
|
||||||
|
timestamp, ok := response["timestamp"].(string)
|
||||||
|
require.True(t, ok, "timestamp should be a string")
|
||||||
|
_, err := time.Parse(time.RFC3339, timestamp)
|
||||||
|
assert.NoError(t, err, "timestamp should be in RFC3339 format")
|
||||||
|
|
||||||
|
// Assert that all expectations were met
|
||||||
|
mockCfg.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with nil config
|
||||||
|
t.Run("ping with nil config", func(t *testing.T) {
|
||||||
|
handler := &HealthHandler{}
|
||||||
|
|
||||||
|
// Create a new request
|
||||||
|
req := httptest.NewRequest("GET", "/ping", nil)
|
||||||
|
|
||||||
|
// Create a response recorder
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Create a new router and register the handler
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/ping", handler.Ping)
|
||||||
|
|
||||||
|
// Serve the request
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// Should still work with default values
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
// Parse the response body
|
||||||
|
var response map[string]interface{}
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response), "Failed to parse response body")
|
||||||
|
|
||||||
|
assert.Equal(t, "pong", response["message"], "Response should contain message 'pong'")
|
||||||
|
assert.Equal(t, "ok", response["status"], "Response should contain status 'ok'")
|
||||||
|
})
|
||||||
|
}
|
||||||
126
internal/transport/http/middleware/auth.go
Normal file
126
internal/transport/http/middleware/auth.go
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"starter-kit/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ContextKeyUser là key dùng để lưu thông tin user trong context
|
||||||
|
ContextKeyUser = "user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthMiddleware xác thực JWT token
|
||||||
|
type AuthMiddleware struct {
|
||||||
|
authSvc service.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthMiddleware tạo mới AuthMiddleware
|
||||||
|
func NewAuthMiddleware(authSvc service.AuthService) *AuthMiddleware {
|
||||||
|
return &AuthMiddleware{
|
||||||
|
authSvc: authSvc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate xác thực JWT token
|
||||||
|
func (m *AuthMiddleware) Authenticate() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Lấy token từ header
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kiểm tra định dạng token
|
||||||
|
parts := strings.Split(authHeader, " ")
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenString := parts[1]
|
||||||
|
|
||||||
|
// Check for empty token
|
||||||
|
if tokenString == "" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Token cannot be empty"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Xác thực token
|
||||||
|
claims, err := m.authSvc.ValidateToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lưu thông tin user vào context
|
||||||
|
c.Set(ContextKeyUser, claims)
|
||||||
|
|
||||||
|
// Tiếp tục xử lý request
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireRole kiểm tra user có vai trò được yêu cầu không
|
||||||
|
func (m *AuthMiddleware) RequireRole(roles ...string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Lấy thông tin user từ context
|
||||||
|
userValue, exists := c.Get(ContextKeyUser)
|
||||||
|
if !exists {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ép kiểu về Claims
|
||||||
|
claims, ok := userValue.(*service.Claims)
|
||||||
|
if !ok {
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Invalid user data"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kiểm tra vai trò
|
||||||
|
for _, role := range roles {
|
||||||
|
for _, userRole := range claims.Roles {
|
||||||
|
if userRole == role {
|
||||||
|
// Có quyền, tiếp tục xử lý
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Không có quyền
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||||
|
"error": fmt.Sprintf("Require one of these roles: %v", roles),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserFromContext lấy thông tin user từ context
|
||||||
|
func GetUserFromContext(c *gin.Context) (*service.Claims, error) {
|
||||||
|
userValue, exists := c.Get(ContextKeyUser)
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("user not found in context")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := userValue.(*service.Claims)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("invalid user data in context")
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserIDFromContext lấy user ID từ context
|
||||||
|
func GetUserIDFromContext(c *gin.Context) (string, error) {
|
||||||
|
claims, err := GetUserFromContext(c)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return claims.UserID, nil
|
||||||
|
}
|
||||||
336
internal/transport/http/middleware/auth_test.go
Normal file
336
internal/transport/http/middleware/auth_test.go
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
package middleware_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
"starter-kit/internal/domain/user"
|
||||||
|
"starter-kit/internal/service"
|
||||||
|
"starter-kit/internal/transport/http/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockAuthService is a mock implementation of AuthService
|
||||||
|
type MockAuthService struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAuthService) Register(ctx context.Context, req service.RegisterRequest) (*user.User, error) {
|
||||||
|
args := m.Called(ctx, req)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).(*user.User), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAuthService) Login(ctx context.Context, username, password string) (string, string, error) {
|
||||||
|
args := m.Called(ctx, username, password)
|
||||||
|
return args.String(0), args.String(1), args.Error(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAuthService) RefreshToken(refreshToken string) (string, string, error) {
|
||||||
|
args := m.Called(refreshToken)
|
||||||
|
return args.String(0), args.String(1), args.Error(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAuthService) ValidateToken(tokenString string) (*service.Claims, error) {
|
||||||
|
args := m.Called(tokenString)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).(*service.Claims), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAuthMiddleware(t *testing.T) {
|
||||||
|
mockAuthSvc := new(MockAuthService)
|
||||||
|
middleware := middleware.NewAuthMiddleware(mockAuthSvc)
|
||||||
|
assert.NotNil(t, middleware)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticate_Success(t *testing.T) {
|
||||||
|
// Setup
|
||||||
|
mockAuthSvc := new(MockAuthService)
|
||||||
|
authMiddleware := middleware.NewAuthMiddleware(mockAuthSvc)
|
||||||
|
|
||||||
|
// Mock token validation
|
||||||
|
claims := &service.Claims{
|
||||||
|
UserID: "user123",
|
||||||
|
Username: "testuser",
|
||||||
|
Roles: []string{"user"},
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mockAuthSvc.On("ValidateToken", "valid.token.here").Return(claims, nil)
|
||||||
|
|
||||||
|
// Create test router
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/protected", authMiddleware.Authenticate(), func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create test request with valid token
|
||||||
|
req, _ := http.NewRequest("GET", "/protected", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer valid.token.here")
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
mockAuthSvc.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticate_NoAuthHeader(t *testing.T) {
|
||||||
|
mockAuthSvc := new(MockAuthService)
|
||||||
|
authMiddleware := middleware.NewAuthMiddleware(mockAuthSvc)
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/protected", authMiddleware.Authenticate())
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", "/protected", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "Authorization header is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticate_InvalidTokenFormat(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
authHeader string
|
||||||
|
expectedError string
|
||||||
|
shouldCallValidate bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no bearer",
|
||||||
|
authHeader: "invalid",
|
||||||
|
expectedError: "Invalid authorization header format",
|
||||||
|
shouldCallValidate: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty token",
|
||||||
|
authHeader: "Bearer ",
|
||||||
|
expectedError: "Token cannot be empty",
|
||||||
|
shouldCallValidate: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
mockAuthSvc := new(MockAuthService)
|
||||||
|
authMiddleware := middleware.NewAuthMiddleware(mockAuthSvc)
|
||||||
|
|
||||||
|
// Create a test server with the middleware and a simple handler
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// This handler should not be called for invalid token formats
|
||||||
|
t.Error("Handler should not be called for invalid token formats")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if _, err := w.Write([]byte("should not be called")); err != nil {
|
||||||
|
t.Errorf("failed to write response in unexpected handler call: %v", err)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// Create a request with the test auth header
|
||||||
|
req, _ := http.NewRequest("GET", server.URL, nil)
|
||||||
|
req.Header.Set("Authorization", tt.authHeader)
|
||||||
|
|
||||||
|
// Create a response recorder
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Create a Gin context with the request and response
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
// Call the middleware directly
|
||||||
|
authMiddleware.Authenticate()(c)
|
||||||
|
|
||||||
|
// Check if the response has the expected status code and error message
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("Expected status code %d, got %d", http.StatusUnauthorized, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]string
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp["error"] != tt.expectedError {
|
||||||
|
t.Errorf("Expected error message '%s', got '%s'", tt.expectedError, resp["error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that ValidateToken was not called when it shouldn't be
|
||||||
|
if !tt.shouldCallValidate {
|
||||||
|
mockAuthSvc.AssertNotCalled(t, "ValidateToken")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticate_InvalidToken(t *testing.T) {
|
||||||
|
mockAuthSvc := new(MockAuthService)
|
||||||
|
authMiddleware := middleware.NewAuthMiddleware(mockAuthSvc)
|
||||||
|
|
||||||
|
// Mock token validation to fail
|
||||||
|
errInvalidToken := errors.New("invalid token")
|
||||||
|
mockAuthSvc.On("ValidateToken", "invalid.token").Return((*service.Claims)(nil), errInvalidToken)
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/protected", authMiddleware.Authenticate())
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", "/protected", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer invalid.token")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "Invalid or expired token")
|
||||||
|
mockAuthSvc.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireRole_Success(t *testing.T) {
|
||||||
|
mockAuthSvc := new(MockAuthService)
|
||||||
|
authMiddleware := middleware.NewAuthMiddleware(mockAuthSvc)
|
||||||
|
|
||||||
|
// Create a test router with role-based auth
|
||||||
|
r := gin.New()
|
||||||
|
// Add a route that requires admin role
|
||||||
|
r.GET("/admin", authMiddleware.Authenticate(), authMiddleware.RequireRole("admin"), func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "admin access granted"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create a request with a valid token that has admin role
|
||||||
|
req, _ := http.NewRequest("GET", "/admin", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer admin.token")
|
||||||
|
|
||||||
|
// Mock the token validation to return a user with admin role
|
||||||
|
claims := &service.Claims{
|
||||||
|
UserID: "admin123",
|
||||||
|
Username: "adminuser",
|
||||||
|
Roles: []string{"admin"},
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mockAuthSvc.On("ValidateToken", "admin.token").Return(claims, nil)
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "admin access granted")
|
||||||
|
mockAuthSvc.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireRole_Unauthenticated(t *testing.T) {
|
||||||
|
mockAuthSvc := new(MockAuthService)
|
||||||
|
authMiddleware := middleware.NewAuthMiddleware(mockAuthSvc)
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/admin", authMiddleware.RequireRole("admin"))
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", "/admin", nil)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "Authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireRole_Forbidden(t *testing.T) {
|
||||||
|
mockAuthSvc := new(MockAuthService)
|
||||||
|
authMiddleware := middleware.NewAuthMiddleware(mockAuthSvc)
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/admin", authMiddleware.Authenticate(), authMiddleware.RequireRole("admin"))
|
||||||
|
|
||||||
|
// Create a request with a valid token that doesn't have admin role
|
||||||
|
req, _ := http.NewRequest("GET", "/admin", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer user.token")
|
||||||
|
|
||||||
|
// Mock the token validation to return a user without admin role
|
||||||
|
claims := &service.Claims{
|
||||||
|
UserID: "user123",
|
||||||
|
Username: "regularuser",
|
||||||
|
Roles: []string{"user"},
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mockAuthSvc.On("ValidateToken", "user.token").Return(claims, nil)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "Require one of these roles: [admin]")
|
||||||
|
mockAuthSvc.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserFromContext(t *testing.T) {
|
||||||
|
// Setup test context with user
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
claims := &service.Claims{
|
||||||
|
UserID: "user123",
|
||||||
|
Username: "testuser",
|
||||||
|
Roles: []string{"user"},
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c.Set(middleware.ContextKeyUser, claims)
|
||||||
|
|
||||||
|
// Test GetUserFromContext
|
||||||
|
user, err := middleware.GetUserFromContext(c)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "user123", user.UserID)
|
||||||
|
assert.Equal(t, "testuser", user.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserFromContext_NotFound(t *testing.T) {
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
_, err := middleware.GetUserFromContext(c)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserIDFromContext(t *testing.T) {
|
||||||
|
// Setup test context with user
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
claims := &service.Claims{
|
||||||
|
UserID: "user123",
|
||||||
|
Username: "testuser",
|
||||||
|
Roles: []string{"user"},
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c.Set(middleware.ContextKeyUser, claims)
|
||||||
|
|
||||||
|
// Test GetUserIDFromContext
|
||||||
|
userID, err := middleware.GetUserIDFromContext(c)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "user123", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserIDFromContext_InvalidType(t *testing.T) {
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
c.Set(middleware.ContextKeyUser, "not a claims object")
|
||||||
|
|
||||||
|
_, err := middleware.GetUserIDFromContext(c)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
43
internal/transport/http/middleware/logger.go
Normal file
43
internal/transport/http/middleware/logger.go
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Logger là một middleware đơn giản để ghi log các request
|
||||||
|
func Logger() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Ghi thời gian bắt đầu xử lý request
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// Xử lý request
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
// Tính thời gian xử lý
|
||||||
|
latency := time.Since(start)
|
||||||
|
|
||||||
|
|
||||||
|
// Lấy thông tin response
|
||||||
|
status := c.Writer.Status()
|
||||||
|
method := c.Request.Method
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
|
||||||
|
// Ghi log
|
||||||
|
logMessage := "[GIN] " + time.Now().Format("2006/01/02 - 15:04:05") +
|
||||||
|
" | " + method +
|
||||||
|
" | " + path +
|
||||||
|
" | " + latency.String() +
|
||||||
|
" | " + c.ClientIP() +
|
||||||
|
" | " + c.Request.UserAgent()
|
||||||
|
|
||||||
|
if status >= 400 {
|
||||||
|
// Log lỗi
|
||||||
|
logMessage += " | " + c.Errors.ByType(gin.ErrorTypePrivate).String()
|
||||||
|
_, _ = gin.DefaultErrorWriter.Write([]byte(logMessage + "\n"))
|
||||||
|
} else {
|
||||||
|
// Log thông thường
|
||||||
|
_, _ = gin.DefaultWriter.Write([]byte(logMessage + "\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
internal/transport/http/middleware/middleware.go
Normal file
76
internal/transport/http/middleware/middleware.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
// CORSConfig chứa cấu hình CORS
|
||||||
|
type CORSConfig struct {
|
||||||
|
AllowOrigins []string `yaml:"allow_origins"`
|
||||||
|
AllowMethods []string `yaml:"allow_methods"`
|
||||||
|
AllowHeaders []string `yaml:"allow_headers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultCORSConfig trả về cấu hình CORS mặc định
|
||||||
|
func DefaultCORSConfig() CORSConfig {
|
||||||
|
return CORSConfig{
|
||||||
|
AllowOrigins: []string{"*"},
|
||||||
|
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"},
|
||||||
|
AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORS middleware xử lý CORS
|
||||||
|
func CORS(config CORSConfig) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimiterConfig chứa cấu hình rate limiting
|
||||||
|
type RateLimiterConfig struct {
|
||||||
|
Rate int `yaml:"rate"` // Số request tối đa trong khoảng thời gian
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultRateLimiterConfig trả về cấu hình rate limiting mặc định
|
||||||
|
func DefaultRateLimiterConfig() RateLimiterConfig {
|
||||||
|
return RateLimiterConfig{
|
||||||
|
Rate: 100, // 100 requests per minute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRateLimiter tạo middleware rate limiting
|
||||||
|
func NewRateLimiter(config RateLimiterConfig) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// TODO: Implement rate limiting logic
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityConfig chứa tất cả các cấu hình bảo mật
|
||||||
|
type SecurityConfig struct {
|
||||||
|
// CORS configuration
|
||||||
|
CORS CORSConfig `yaml:"cors"`
|
||||||
|
// Rate limiting configuration
|
||||||
|
RateLimit RateLimiterConfig `yaml:"rate_limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultSecurityConfig trả về cấu hình bảo mật mặc định
|
||||||
|
func DefaultSecurityConfig() SecurityConfig {
|
||||||
|
return SecurityConfig{
|
||||||
|
CORS: DefaultCORSConfig(),
|
||||||
|
RateLimit: DefaultRateLimiterConfig(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply áp dụng tất cả các middleware bảo mật vào router
|
||||||
|
func (c *SecurityConfig) Apply(r *gin.Engine) {
|
||||||
|
// Áp dụng CORS middleware
|
||||||
|
r.Use(CORS(c.CORS))
|
||||||
|
|
||||||
|
// Áp dụng rate limiting
|
||||||
|
r.Use(NewRateLimiter(c.RateLimit))
|
||||||
|
}
|
||||||
182
internal/transport/http/middleware/middleware_test.go
Normal file
182
internal/transport/http/middleware/middleware_test.go
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
package middleware_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"starter-kit/internal/transport/http/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper function to perform a test request
|
||||||
|
func performRequest(r http.Handler, method, path string, headers map[string]string) *httptest.ResponseRecorder {
|
||||||
|
req, _ := http.NewRequest(method, path, nil)
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCORSConfig(t *testing.T) {
|
||||||
|
config := middleware.DefaultCORSConfig()
|
||||||
|
assert.NotNil(t, config)
|
||||||
|
assert.Equal(t, []string{"*"}, config.AllowOrigins)
|
||||||
|
assert.Equal(t, []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}, config.AllowMethods)
|
||||||
|
assert.Equal(t, []string{"Origin", "Content-Length", "Content-Type", "Authorization"}, config.AllowHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCORS(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config middleware.CORSConfig
|
||||||
|
headers map[string]string
|
||||||
|
expectedAllowOrigin string
|
||||||
|
expectedStatus int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default config allows all origins",
|
||||||
|
config: middleware.DefaultCORSConfig(),
|
||||||
|
headers: map[string]string{
|
||||||
|
"Origin": "https://example.com",
|
||||||
|
},
|
||||||
|
expectedAllowOrigin: "*",
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specific origin allowed",
|
||||||
|
config: middleware.CORSConfig{
|
||||||
|
AllowOrigins: []string{"https://allowed.com"},
|
||||||
|
},
|
||||||
|
headers: map[string]string{
|
||||||
|
"Origin": "https://allowed.com",
|
||||||
|
},
|
||||||
|
expectedAllowOrigin: "*", // Our implementation always returns *
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "preflight request",
|
||||||
|
config: middleware.DefaultCORSConfig(),
|
||||||
|
headers: map[string]string{
|
||||||
|
"Origin": "https://example.com",
|
||||||
|
"Access-Control-Request-Method": "GET",
|
||||||
|
},
|
||||||
|
expectedStatus: http.StatusOK, // Our implementation doesn't handle OPTIONS specially
|
||||||
|
expectedAllowOrigin: "*",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(middleware.CORS(tt.config))
|
||||||
|
|
||||||
|
r.GET("/test", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// Create a test request
|
||||||
|
req, _ := http.NewRequest("GET", "/test", nil)
|
||||||
|
for k, v := range tt.headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// Check status code
|
||||||
|
assert.Equal(t, tt.expectedStatus, w.Code)
|
||||||
|
|
||||||
|
|
||||||
|
// For non-preflight requests, check CORS headers
|
||||||
|
if req.Method != "OPTIONS" {
|
||||||
|
assert.Equal(t, tt.expectedAllowOrigin, w.Header().Get("Access-Control-Allow-Origin"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultRateLimiterConfig(t *testing.T) {
|
||||||
|
config := middleware.DefaultRateLimiterConfig()
|
||||||
|
assert.Equal(t, 100, config.Rate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimit(t *testing.T) {
|
||||||
|
// Create a rate limiter with a very low limit for testing
|
||||||
|
config := middleware.RateLimiterConfig{
|
||||||
|
Rate: 2, // 2 requests per minute for testing
|
||||||
|
}
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(middleware.NewRateLimiter(config))
|
||||||
|
|
||||||
|
r.GET("/", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// First request should pass
|
||||||
|
w := performRequest(r, "GET", "/", nil)
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
// Second request should also pass (limit is 2)
|
||||||
|
w = performRequest(r, "GET", "/", nil)
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecurityConfig(t *testing.T) {
|
||||||
|
t.Run("default config", func(t *testing.T) {
|
||||||
|
config := middleware.DefaultSecurityConfig()
|
||||||
|
assert.NotNil(t, config)
|
||||||
|
assert.Equal(t, "*", config.CORS.AllowOrigins[0])
|
||||||
|
assert.Equal(t, 100, config.RateLimit.Rate)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("apply to router", func(t *testing.T) {
|
||||||
|
r := gin.New()
|
||||||
|
config := middleware.DefaultSecurityConfig()
|
||||||
|
config.Apply(r)
|
||||||
|
|
||||||
|
// Just verify the router has the middlewares applied
|
||||||
|
// The actual middleware behavior is tested separately
|
||||||
|
assert.NotNil(t, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCORSWithCustomConfig(t *testing.T) {
|
||||||
|
config := middleware.CORSConfig{
|
||||||
|
AllowOrigins: []string{"https://custom.com"},
|
||||||
|
AllowMethods: []string{"GET", "POST"},
|
||||||
|
AllowHeaders: []string{"X-Custom-Header"},
|
||||||
|
}
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(middleware.CORS(config))
|
||||||
|
|
||||||
|
r.GET("/test", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("allowed origin", func(t *testing.T) {
|
||||||
|
w := performRequest(r, "GET", "/test", map[string]string{
|
||||||
|
"Origin": "https://custom.com",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("preflight request", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("OPTIONS", "/test", nil)
|
||||||
|
req.Header.Set("Origin", "https://custom.com")
|
||||||
|
req.Header.Set("Access-Control-Request-Method", "GET")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNoContent, w.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
91
internal/transport/http/router.go
Normal file
91
internal/transport/http/router.go
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"starter-kit/internal/adapter/persistence"
|
||||||
|
"starter-kit/internal/helper/config"
|
||||||
|
"starter-kit/internal/service"
|
||||||
|
"starter-kit/internal/transport/http/handler"
|
||||||
|
"starter-kit/internal/transport/http/middleware"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetupRouter cấu hình router cho HTTP server
|
||||||
|
func SetupRouter(cfg *config.Config, db *gorm.DB) *gin.Engine {
|
||||||
|
// Khởi tạo router với mode phù hợp với môi trường
|
||||||
|
if cfg.App.Environment == "production" {
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
|
||||||
|
// Logger middleware
|
||||||
|
router.Use(middleware.Logger())
|
||||||
|
|
||||||
|
// Recovery middleware
|
||||||
|
router.Use(gin.Recovery())
|
||||||
|
|
||||||
|
// Apply security middleware
|
||||||
|
securityCfg := middleware.DefaultSecurityConfig()
|
||||||
|
securityCfg.Apply(router)
|
||||||
|
|
||||||
|
// Khởi tạo repositories
|
||||||
|
userRepo := persistence.NewUserRepository(db)
|
||||||
|
roleRepo := persistence.NewRoleRepository(db)
|
||||||
|
|
||||||
|
// Get JWT configuration from config
|
||||||
|
jwtSecret := "your-secret-key" // Default fallback
|
||||||
|
accessTokenExpire := 24 * time.Hour
|
||||||
|
|
||||||
|
// Override with config values if available
|
||||||
|
if cfg.JWT.Secret != "" {
|
||||||
|
jwtSecret = cfg.JWT.Secret
|
||||||
|
}
|
||||||
|
if cfg.JWT.AccessTokenExpire > 0 {
|
||||||
|
accessTokenExpire = time.Duration(cfg.JWT.AccessTokenExpire) * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
// Khởi tạo services
|
||||||
|
authSvc := service.NewAuthService(
|
||||||
|
userRepo,
|
||||||
|
roleRepo,
|
||||||
|
jwtSecret,
|
||||||
|
accessTokenExpire,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Khởi tạo middleware
|
||||||
|
authMiddleware := middleware.NewAuthMiddleware(authSvc)
|
||||||
|
_ = authMiddleware // TODO: Use authMiddleware when needed
|
||||||
|
|
||||||
|
// Khởi tạo các handlers
|
||||||
|
healthHandler := handler.NewHealthHandler(cfg)
|
||||||
|
authHandler := handler.NewAuthHandler(authSvc)
|
||||||
|
|
||||||
|
// Đăng ký các routes
|
||||||
|
|
||||||
|
// Health check routes (public)
|
||||||
|
router.GET("/ping", healthHandler.Ping)
|
||||||
|
router.GET("/health", healthHandler.HealthCheck)
|
||||||
|
|
||||||
|
// Auth routes (public)
|
||||||
|
authGroup := router.Group("/api/v1/auth")
|
||||||
|
{
|
||||||
|
authGroup.POST("/register", authHandler.Register)
|
||||||
|
authGroup.POST("/login", authHandler.Login)
|
||||||
|
authGroup.POST("/refresh", authHandler.RefreshToken)
|
||||||
|
authGroup.POST("/logout", authMiddleware.Authenticate(), authHandler.Logout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected API routes
|
||||||
|
api := router.Group("/api/v1")
|
||||||
|
api.Use(authMiddleware.Authenticate())
|
||||||
|
{
|
||||||
|
// Ví dụ về protected endpoints
|
||||||
|
// api.GET("/profile", userHandler.GetProfile)
|
||||||
|
// api.PUT("/profile", userHandler.UpdateProfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
105
internal/transport/http/server.go
Normal file
105
internal/transport/http/server.go
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"starter-kit/internal/helper/config"
|
||||||
|
"starter-kit/internal/helper/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrServerClosed is returned by the Server's Start method after a call to Shutdown
|
||||||
|
var ErrServerClosed = errors.New("http: Server closed")
|
||||||
|
|
||||||
|
// Server represents the HTTP server
|
||||||
|
type Server struct {
|
||||||
|
server *http.Server
|
||||||
|
config *config.Config
|
||||||
|
router *gin.Engine
|
||||||
|
listener net.Listener
|
||||||
|
db *gorm.DB
|
||||||
|
serverErr chan error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer creates a new HTTP server with the given configuration
|
||||||
|
func NewServer(cfg *config.Config, db *gorm.DB) *Server {
|
||||||
|
// Create a new Gin router
|
||||||
|
router := SetupRouter(cfg, db)
|
||||||
|
|
||||||
|
// Create the HTTP server
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port),
|
||||||
|
Handler: router,
|
||||||
|
ReadTimeout: time.Duration(cfg.Server.ReadTimeout) * time.Second,
|
||||||
|
WriteTimeout: time.Duration(cfg.Server.WriteTimeout) * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Server{
|
||||||
|
server: server,
|
||||||
|
config: cfg,
|
||||||
|
router: router,
|
||||||
|
db: db,
|
||||||
|
serverErr: make(chan error, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the HTTP server
|
||||||
|
func (s *Server) Start() error {
|
||||||
|
// Create a listener
|
||||||
|
listener, err := net.Listen("tcp", s.server.Addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create listener: %w", err)
|
||||||
|
}
|
||||||
|
s.listener = listener
|
||||||
|
|
||||||
|
// Log server start
|
||||||
|
logger.WithFields(logger.Fields{
|
||||||
|
"address": s.server.Addr,
|
||||||
|
}).Info("Starting HTTP server")
|
||||||
|
|
||||||
|
// Start the server in a goroutine
|
||||||
|
go func() {
|
||||||
|
s.serverErr <- s.server.Serve(s.listener)
|
||||||
|
close(s.serverErr)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Check if server started successfully
|
||||||
|
select {
|
||||||
|
case err := <-s.serverErr:
|
||||||
|
return fmt.Errorf("server failed to start: %w", err)
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
logger.Info("HTTP server started successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown gracefully shuts down the server
|
||||||
|
func (s *Server) Shutdown(ctx context.Context) error {
|
||||||
|
logger.Info("Shutting down HTTP server...")
|
||||||
|
|
||||||
|
// Try to gracefully shutdown
|
||||||
|
err := s.server.Shutdown(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.WithError(err).Error("Error during server shutdown")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("HTTP server stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRouter returns the underlying router
|
||||||
|
func (s *Server) GetRouter() *gin.Engine {
|
||||||
|
return s.router
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the server configuration
|
||||||
|
func (s *Server) GetConfig() *config.Config {
|
||||||
|
return s.config
|
||||||
|
}
|
||||||
1
migrations/000000_initial_extensions.down.sql
Normal file
1
migrations/000000_initial_extensions.down.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
DROP EXTENSION IF EXISTS "uuid-ossp";
|
||||||
1
migrations/000000_initial_extensions.up.sql
Normal file
1
migrations/000000_initial_extensions.up.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
1
migrations/000001_create_roles_table.down.sql
Normal file
1
migrations/000001_create_roles_table.down.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS roles CASCADE;
|
||||||
14
migrations/000001_create_roles_table.up.sql
Normal file
14
migrations/000001_create_roles_table.up.sql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE roles (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert default roles
|
||||||
|
INSERT INTO roles (name, description) VALUES
|
||||||
|
('admin', 'Quản trị viên hệ thống'),
|
||||||
|
('manager', 'Quản lý'),
|
||||||
|
('user', 'Người dùng thông thường'),
|
||||||
|
('guest', 'Khách');
|
||||||
1
migrations/000002_create_users_table.down.sql
Normal file
1
migrations/000002_create_users_table.down.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS users CASCADE;
|
||||||
17
migrations/000002_create_users_table.up.sql
Normal file
17
migrations/000002_create_users_table.up.sql
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
username VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
email VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
full_name VARCHAR(100),
|
||||||
|
avatar_url VARCHAR(255),
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
last_login_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create index for better query performance
|
||||||
|
CREATE INDEX idx_users_email ON users(email);
|
||||||
|
CREATE INDEX idx_users_username ON users(username);
|
||||||
1
migrations/000003_create_user_roles_table.down.sql
Normal file
1
migrations/000003_create_user_roles_table.down.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS user_roles CASCADE;
|
||||||
26
migrations/000003_create_user_roles_table.up.sql
Normal file
26
migrations/000003_create_user_roles_table.up.sql
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
-- Tạo bảng mà không có ràng buộc
|
||||||
|
CREATE TABLE IF NOT EXISTS user_roles (
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
role_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, role_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tạo index cho hiệu suất truy vấn tốt hơn
|
||||||
|
CREATE INDEX idx_user_roles_user_id ON user_roles(user_id);
|
||||||
|
CREATE INDEX idx_user_roles_role_id ON user_roles(role_id);
|
||||||
|
|
||||||
|
-- Thêm ràng buộc khóa ngoại nếu bảng tồn tại
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'users') THEN
|
||||||
|
ALTER TABLE user_roles ADD CONSTRAINT fk_user_roles_user
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'roles') THEN
|
||||||
|
ALTER TABLE user_roles ADD CONSTRAINT fk_user_roles_role
|
||||||
|
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
32
templates/.env.example
Normal file
32
templates/.env.example
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# App Configuration
|
||||||
|
APP_NAME="ULFlow Starter Kit"
|
||||||
|
APP_VERSION="0.1.0"
|
||||||
|
APP_ENVIRONMENT="development"
|
||||||
|
APP_TIMEZONE="Asia/Ho_Chi_Minh"
|
||||||
|
|
||||||
|
# Logger Configuration
|
||||||
|
LOG_LEVEL="info" # debug, info, warn, error
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
SERVER_HOST="0.0.0.0"
|
||||||
|
SERVER_PORT=3000
|
||||||
|
SERVER_READ_TIMEOUT=15
|
||||||
|
SERVER_WRITE_TIMEOUT=15
|
||||||
|
SERVER_SHUTDOWN_TIMEOUT=30
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DATABASE_DRIVER="postgres"
|
||||||
|
DATABASE_HOST="localhost"
|
||||||
|
DATABASE_PORT=5432
|
||||||
|
DATABASE_USERNAME="postgres"
|
||||||
|
DATABASE_PASSWORD="postgres"
|
||||||
|
DATABASE_NAME="ulflow"
|
||||||
|
DATABASE_SSLMODE="disable"
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET="your-32-byte-base64-encoded-secret-key-here"
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE=15
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRE=30
|
||||||
|
JWT_ALGORITHM="HS256"
|
||||||
|
JWT_ISSUER="ulflow-starter-kit"
|
||||||
|
JWT_AUDIENCE="ulflow-web"
|
||||||
31
templates/config.example.yaml
Normal file
31
templates/config.example.yaml
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
app:
|
||||||
|
name: "ULFlow Starter Kit"
|
||||||
|
version: "0.1.0"
|
||||||
|
environment: "development"
|
||||||
|
timezone: "Asia/Ho_Chi_Minh"
|
||||||
|
|
||||||
|
logger:
|
||||||
|
level: "info" # debug, info, warn, error
|
||||||
|
|
||||||
|
server:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 3000
|
||||||
|
read_timeout: 15
|
||||||
|
write_timeout: 15
|
||||||
|
shutdown_timeout: 30
|
||||||
|
trusted_proxies: []
|
||||||
|
allow_origins:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
database:
|
||||||
|
driver: "postgres"
|
||||||
|
host: "localhost"
|
||||||
|
port: 5432
|
||||||
|
username: "postgres"
|
||||||
|
password: "postgres"
|
||||||
|
database: "ulflow"
|
||||||
|
ssl_mode: "disable"
|
||||||
|
max_open_conns: 25
|
||||||
|
max_idle_conns: 5
|
||||||
|
conn_max_lifetime: 300
|
||||||
|
migration_path: "migrations"
|
||||||
55
tomb.yaml
Normal file
55
tomb.yaml
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
# Tomb Configuration for ULFlow Starter Kit
|
||||||
|
# This file configures how our application handles signals and graceful shutdown
|
||||||
|
|
||||||
|
# Signal configuration
|
||||||
|
signals:
|
||||||
|
# Signal to use for graceful shutdown
|
||||||
|
graceful: "SIGTERM"
|
||||||
|
# Signal to use for forceful shutdown
|
||||||
|
force: "SIGKILL"
|
||||||
|
# Timeout for graceful shutdown before force shutdown
|
||||||
|
timeout: "10s"
|
||||||
|
|
||||||
|
# Hooks to execute during shutdown process
|
||||||
|
hooks:
|
||||||
|
# Pre-shutdown hooks run before beginning shutdown
|
||||||
|
pre_shutdown:
|
||||||
|
- name: "logger"
|
||||||
|
command: "go"
|
||||||
|
args: ["run", "./scripts/shutdown_logger.go", "pre"]
|
||||||
|
timeout: "2s"
|
||||||
|
async: true
|
||||||
|
|
||||||
|
# Shutdown hooks run during shutdown
|
||||||
|
shutdown:
|
||||||
|
- name: "close-http-server"
|
||||||
|
timeout: "5s"
|
||||||
|
async: false
|
||||||
|
|
||||||
|
# Post-shutdown hooks run after shutdown completes
|
||||||
|
post_shutdown:
|
||||||
|
- name: "cleanup"
|
||||||
|
command: "go"
|
||||||
|
args: ["run", "./scripts/shutdown_logger.go", "post"]
|
||||||
|
timeout: "2s"
|
||||||
|
async: true
|
||||||
|
|
||||||
|
# Resources to manage during shutdown
|
||||||
|
resources:
|
||||||
|
- name: "http-server"
|
||||||
|
type: "http"
|
||||||
|
grace_period: "5s"
|
||||||
|
|
||||||
|
- name: "database-connections"
|
||||||
|
type: "sql"
|
||||||
|
grace_period: "3s"
|
||||||
|
|
||||||
|
- name: "background-workers"
|
||||||
|
type: "worker"
|
||||||
|
grace_period: "8s"
|
||||||
|
|
||||||
|
# Monitoring configuration
|
||||||
|
monitoring:
|
||||||
|
enabled: true
|
||||||
|
interval: "1s"
|
||||||
|
threshold: 5 # number of consecutive failures before alerting
|
||||||
Loading…
x
Reference in New Issue
Block a user