first commit
This commit is contained in:
46
.air.toml
Normal file
46
.air.toml
Normal file
@@ -0,0 +1,46 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = ".\\main.exe"
|
||||
cmd = "make build"
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# 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
|
||||
tmp/
|
||||
|
||||
# IDE specific files
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# .env file
|
||||
.env
|
||||
|
||||
# Project build
|
||||
main
|
||||
*templ.go
|
||||
|
||||
# OS X generated file
|
||||
.DS_Store
|
||||
|
||||
42
.goreleaser.yml
Normal file
42
.goreleaser.yml
Normal file
@@ -0,0 +1,42 @@
|
||||
version: 2
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
|
||||
env:
|
||||
- PACKAGE_PATH=github.com/<user>/<repo>/cmd
|
||||
|
||||
builds:
|
||||
- binary: "{{ .ProjectName }}"
|
||||
main: ./cmd/api
|
||||
goos:
|
||||
- darwin
|
||||
- linux
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
ldflags:
|
||||
- -s -w -X {{.Env.PACKAGE_PATH}}={{.Version}}
|
||||
release:
|
||||
prerelease: auto
|
||||
|
||||
universal_binaries:
|
||||
- replace: true
|
||||
|
||||
archives:
|
||||
- name_template: >
|
||||
{{- .ProjectName }}_{{- .Version }}_{{- title .Os }}_{{- if eq .Arch "amd64" }}x86_64{{- else if eq .Arch "386" }}i386{{- else }}{{ .Arch }}{{ end }}{{- if .Arm }}v{{ .Arm }}{{ end -}}
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
builds_info:
|
||||
group: root
|
||||
owner: root
|
||||
files:
|
||||
- README.md
|
||||
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM golang:1.24.4-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN go build -o main cmd/api/main.go
|
||||
|
||||
FROM alpine:3.20.1 AS prod
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/main /app/main
|
||||
COPY --from=build /app/.env /app/.env
|
||||
EXPOSE 8080
|
||||
CMD ["./main"]
|
||||
|
||||
|
||||
49
Makefile
Normal file
49
Makefile
Normal file
@@ -0,0 +1,49 @@
|
||||
# Simple Makefile for a Go project
|
||||
|
||||
# Build the application
|
||||
all: build test
|
||||
|
||||
build:
|
||||
@echo "Building..."
|
||||
|
||||
|
||||
@go build -o main.exe cmd/api/main.go
|
||||
|
||||
# Run the application
|
||||
run:
|
||||
@go run cmd/api/main.go
|
||||
# Create DB container
|
||||
docker-run:
|
||||
@docker compose up --build
|
||||
|
||||
# Shutdown DB container
|
||||
docker-down:
|
||||
@docker compose down
|
||||
|
||||
# Test the application
|
||||
test:
|
||||
@echo "Testing..."
|
||||
@go test ./... -v
|
||||
# Integrations Tests for the application
|
||||
itest:
|
||||
@echo "Running integration tests..."
|
||||
@go test ./internal/database -v
|
||||
|
||||
# Clean the binary
|
||||
clean:
|
||||
@echo "Cleaning..."
|
||||
@rm -f main
|
||||
|
||||
# Live Reload
|
||||
watch:
|
||||
@powershell -ExecutionPolicy Bypass -Command "if (Get-Command air -ErrorAction SilentlyContinue) { \
|
||||
air; \
|
||||
Write-Output 'Watching...'; \
|
||||
} else { \
|
||||
Write-Output 'Installing air...'; \
|
||||
go install github.com/air-verse/air@latest; \
|
||||
air; \
|
||||
Write-Output 'Watching...'; \
|
||||
}"
|
||||
|
||||
.PHONY: all build run test clean watch docker-run docker-down itest
|
||||
689
README.md
Normal file
689
README.md
Normal file
@@ -0,0 +1,689 @@
|
||||
|
||||
# 🚀 WebSocket API Service - Real-Time Communication
|
||||
|
||||
> **Modern WebSocket API service with advanced real-time communication, client management, and broadcasting capabilities**
|
||||
|
||||
## 📑 Daftar Isi
|
||||
|
||||
- [✨ Fitur Utama](#-fitur-utama)
|
||||
- [🏗️ Arsitektur](#%EF%B8%8F-arsitektur)
|
||||
- [⚡ Quick Start](#-quick-start)
|
||||
- [🔐 Autentikasi](#-autentikasi)
|
||||
- [📊 API Endpoints](#-api-endpoints)
|
||||
- [🛠️ Development](#%EF%B8%8F-development)
|
||||
- [🚀 Deployment](#-deployment)
|
||||
- [📚 Dokumentasi](#-dokumentasi)
|
||||
|
||||
***
|
||||
|
||||
## ✨ Fitur Utama
|
||||
|
||||
### Real-Time Communication
|
||||
|
||||
- **🔄 WebSocket Server** - High-performance WebSocket server dengan auto-reconnect
|
||||
- **🏠 Room Management** - Multi-room support untuk isolated communication
|
||||
- **👥 Client Tracking** - Advanced client identification (IP-based, static ID, generated)
|
||||
- **📡 Real-time Broadcasting** - Server-initiated broadcasts ke semua atau specific clients
|
||||
- **💬 Direct Messaging** - Peer-to-peer messaging antar clients
|
||||
- **🔄 Database Notifications** - PostgreSQL LISTEN/NOTIFY integration
|
||||
|
||||
### Advanced Features
|
||||
|
||||
- **📊 Connection Monitoring** - Real-time statistics dan health monitoring
|
||||
- **🧹 Auto Cleanup** - Automatic cleanup untuk inactive connections
|
||||
- **🔒 Secure Authentication** - JWT-based authentication untuk WebSocket connections
|
||||
- **📈 Performance Metrics** - Built-in performance tracking dan analytics
|
||||
- **🎯 Message Queue** - High-throughput message processing dengan worker pools
|
||||
- **🔍 Activity Logging** - Comprehensive activity logging untuk debugging
|
||||
|
||||
### Developer Experience
|
||||
|
||||
- **🔥 Hot Reload** - Development dengan auto-restart
|
||||
- **🐳 Docker Ready** - Easy deployment dengan Docker
|
||||
- **⚡ Code Generator** - Buat handler dan model otomatis
|
||||
- **🧪 Testing Suite** - Unit dan integration tests
|
||||
- **📊 Health Monitoring** - Monitoring kesehatan aplikasi
|
||||
|
||||
***
|
||||
|
||||
## 🏗️ Arsitektur WebSocket
|
||||
|
||||
### WebSocket Architecture Layers
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ WebSocket Layer │ ← websocket.go, broadcast.go
|
||||
├─────────────────────────────────────┤
|
||||
│ Application Layer │ ← handlers/, middleware/
|
||||
├─────────────────────────────────────┤
|
||||
│ Domain Layer │ ← models/, services/
|
||||
├─────────────────────────────────────┤
|
||||
│ Infrastructure Layer │ ← database/, external APIs
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Core Components
|
||||
|
||||
```
|
||||
api-service/
|
||||
├── 📁 cmd/api/ # 🚪 WebSocket server entry point
|
||||
├── 📁 internal/ # 🏠 Core WebSocket logic
|
||||
│ ├── handlers/websocket/ # 🔄 WebSocket handlers & hub
|
||||
│ ├── middleware/ # 🛡️ Auth & validation middleware
|
||||
│ ├── models/ # 📊 Data structures & validation
|
||||
│ ├── routes/ # 🛣️ API routing
|
||||
│ ├── services/ # 💼 Business logic services
|
||||
│ └── database/ # 💾 Database connections
|
||||
├── 📁 examples/clientsocket/ # 🌐 Vue.js WebSocket client
|
||||
├── 📁 docs/ # 📚 Swagger documentation
|
||||
└── 📁 configs/ # ⚙️ Configuration files
|
||||
```
|
||||
|
||||
### WebSocket Hub Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Hub │ ← Central WebSocket manager
|
||||
├─────────────────────────────────────┤
|
||||
│ • Client Management │ ← Register/unregister clients
|
||||
│ • Message Broadcasting │ ← Broadcast to clients/rooms
|
||||
│ • Room Management │ ← Multi-room support
|
||||
│ • Connection Monitoring │ ← Track active connections
|
||||
│ • Database Notifications │ ← PostgreSQL LISTEN/NOTIFY
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
|
||||
***
|
||||
|
||||
## ⚡ Quick Start
|
||||
|
||||
### 1️⃣ Setup Environment (2 menit)
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone <repository-url>
|
||||
cd api-service
|
||||
|
||||
# Setup environment
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### 2️⃣ Pilih Method Setup
|
||||
|
||||
**🐳 Docker (Recommended)**
|
||||
|
||||
```bash
|
||||
make docker-run
|
||||
```
|
||||
|
||||
**🔧 Manual Setup**
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
go mod download
|
||||
|
||||
# Start server
|
||||
go run cmd/api/main.go
|
||||
```
|
||||
|
||||
### 3️⃣ Setup WebSocket Client
|
||||
|
||||
**Start Vue.js Client Example:**
|
||||
|
||||
```bash
|
||||
cd examples/clientsocket
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 4️⃣ Verify Installation
|
||||
|
||||
| Service | URL | Status |
|
||||
| :-- | :-- | :-- |
|
||||
| **WebSocket API** | ws://localhost:8080/api/v1/ws | ✅ |
|
||||
| **WebSocket Client** | http://localhost:3000 | 🌐 |
|
||||
| **API Documentation** | http://localhost:8080/swagger/index.html | 📖 |
|
||||
| **Health Check** | http://localhost:8080/api/sistem/health | 💚 |
|
||||
|
||||
|
||||
***
|
||||
|
||||
## 🔐 Autentikasi
|
||||
|
||||
### Login \& Mendapatkan Token
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "admin",
|
||||
"password": "password"
|
||||
}'
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"expires_in": 3600,
|
||||
"user": {
|
||||
"id": "123",
|
||||
"username": "admin",
|
||||
"role": "admin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Menggunakan Token
|
||||
|
||||
```bash
|
||||
curl -X GET http://localhost:8080/api/v1/products \
|
||||
-H "Authorization: Bearer <your-token>"
|
||||
```
|
||||
|
||||
|
||||
### Demo Accounts
|
||||
|
||||
| Username | Password | Role | Akses |
|
||||
| :-- | :-- | :-- | :-- |
|
||||
| `admin` | `password` | Admin | Semua endpoint |
|
||||
| `user` | `password` | User | Read-only |
|
||||
|
||||
|
||||
***
|
||||
|
||||
## 📊 API Endpoints
|
||||
|
||||
### 🔄 WebSocket Endpoints
|
||||
|
||||
#### Main WebSocket Connection
|
||||
|
||||
| Protocol | Endpoint | Deskripsi |
|
||||
| :-- | :-- | :-- |
|
||||
| `WS` | `ws://localhost:8080/api/v1/ws` | WebSocket connection utama |
|
||||
|
||||
**Connection Parameters:**
|
||||
|
||||
```javascript
|
||||
// Basic connection
|
||||
const ws = new WebSocket('ws://localhost:8080/api/v1/ws');
|
||||
```
|
||||
|
||||
#### WebSocket Management API
|
||||
|
||||
| Method | Endpoint | Deskripsi |
|
||||
| :-- | :-- | :-- |
|
||||
| `GET` | `/api/websocket/stats` | WebSocket connection statistics |
|
||||
| `GET` | `/api/websocket/clients` | List all connected clients |
|
||||
| `GET` | `/api/websocket/rooms` | List all active rooms |
|
||||
| `POST` | `/api/websocket/broadcast` | Server-initiated broadcast |
|
||||
| `POST` | `/api/websocket/broadcast/room/:room` | Broadcast to specific room |
|
||||
| `POST` | `/api/websocket/send/:clientId` | Send message to specific client |
|
||||
| `POST` | `/api/websocket/cleanup/inactive` | Cleanup inactive clients |
|
||||
|
||||
### 🌍 Public REST Endpoints
|
||||
|
||||
| Method | Endpoint | Deskripsi |
|
||||
| :-- | :-- | :-- |
|
||||
| `POST` | `/api/v1/auth/login` | Login pengguna |
|
||||
| `POST` | `/api/v1/auth/register` | Registrasi pengguna baru |
|
||||
| `GET` | `/api/sistem/health` | Status kesehatan API |
|
||||
| `GET` | `/api/sistem/info` | System information |
|
||||
|
||||
### 🔒 Protected REST Endpoints
|
||||
|
||||
#### User Management
|
||||
|
||||
| Method | Endpoint | Deskripsi |
|
||||
| :-- | :-- | :-- |
|
||||
| `GET` | `/api/v1/auth/me` | Profile pengguna |
|
||||
| `PUT` | `/api/v1/auth/me` | Update profile |
|
||||
|
||||
#### Retribusi Management
|
||||
|
||||
| Method | Endpoint | Deskripsi |
|
||||
| :-- | :-- | :-- |
|
||||
| `GET` | `/api/v1/retribusi` | List semua retribusi |
|
||||
| `GET` | `/api/v1/retribusi/dynamic` | Query dengan filter dinamis |
|
||||
| `GET` | `/api/v1/retribusi/search` | Search retribusi advanced |
|
||||
| `GET` | `/api/v1/retribusi/id/:id` | Detail retribusi by ID |
|
||||
| `POST` | `/api/v1/retribusi` | Buat retribusi baru |
|
||||
| `PUT` | `/api/v1/retribusi/id/:id` | Update retribusi |
|
||||
| `DELETE` | `/api/v1/retribusi/id/:id` | Hapus retribusi |
|
||||
|
||||
|
||||
***
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Code Generation (30 detik)
|
||||
|
||||
**🎯 Generate CRUD Lengkap**
|
||||
|
||||
```bash
|
||||
# Generate handler untuk entity baru
|
||||
go run tools/general/generate-handler.go product get post put delete
|
||||
|
||||
# Generate dengan fitur advanced
|
||||
go run tools/general/generate-handler.go orders get post put delete dynamic search stats
|
||||
```
|
||||
|
||||
**🏥 Generate BPJS Handler**
|
||||
|
||||
```bash
|
||||
# Single service
|
||||
go run tools/bpjs/generate-bpjs-handler.go tools/bpjs/reference/peserta get
|
||||
|
||||
# Semua service dari config
|
||||
go run tools/bpjs/generate-handler.go tools/bpjs/services-config-bpjs.yaml
|
||||
```
|
||||
|
||||
**🩺 Generate SATUSEHAT Handler**
|
||||
|
||||
```bash
|
||||
go run tools/satusehat/generate-satusehat-handler.go tools/satusehat/services-config-satusehat.yaml patient
|
||||
```
|
||||
|
||||
|
||||
### Development Commands
|
||||
|
||||
```bash
|
||||
# 🔥 Development dengan hot reload
|
||||
make watch
|
||||
|
||||
# 🧪 Testing
|
||||
make test # Unit tests
|
||||
make itest # Integration tests
|
||||
make test-all # Semua tests
|
||||
|
||||
# 📖 Update dokumentasi
|
||||
make docs # Generate Swagger docs
|
||||
|
||||
# 🔍 Code quality
|
||||
make lint # Linting
|
||||
make format # Format code
|
||||
```
|
||||
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
**📁 .env File:**
|
||||
|
||||
```bash
|
||||
# Database
|
||||
BLUEPRINT_DB_HOST=localhost
|
||||
BLUEPRINT_DB_PORT=5432
|
||||
BLUEPRINT_DB_USERNAME=postgres
|
||||
BLUEPRINT_DB_PASSWORD=postgres
|
||||
BLUEPRINT_DB_DATABASE=api_service
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-super-secret-key-change-in-production
|
||||
|
||||
# External APIs
|
||||
BPJS_BASE_URL=https://api.bpjs-kesehatan.go.id
|
||||
SATUSEHAT_BASE_URL=https://api.satusehat.kemkes.go.id
|
||||
|
||||
# Application
|
||||
APP_ENV=development
|
||||
APP_PORT=8080
|
||||
LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
|
||||
***
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### 🐳 Docker Deployment
|
||||
|
||||
**Development:**
|
||||
|
||||
```bash
|
||||
# Start semua services
|
||||
make docker-run
|
||||
|
||||
# Stop services
|
||||
make docker-down
|
||||
|
||||
# Rebuild dan restart
|
||||
make docker-rebuild
|
||||
```
|
||||
|
||||
**Production:**
|
||||
|
||||
```bash
|
||||
# Build production image
|
||||
docker build -t api-service:prod .
|
||||
|
||||
# Run production container
|
||||
docker run -d \
|
||||
--name api-service \
|
||||
-p 8080:8080 \
|
||||
--env-file .env.prod \
|
||||
api-service:prod
|
||||
```
|
||||
|
||||
|
||||
### 🔧 Manual Deployment
|
||||
|
||||
```bash
|
||||
# Build aplikasi
|
||||
make build
|
||||
|
||||
# Run migrations
|
||||
./scripts/migrate.sh up
|
||||
|
||||
# Start server
|
||||
./bin/api-service
|
||||
```
|
||||
|
||||
|
||||
***
|
||||
|
||||
## 📚 Dokumentasi
|
||||
|
||||
### 📖 Interactive API Documentation
|
||||
|
||||
Kunjungi **Swagger UI** di: http://localhost:8080/swagger/index.html
|
||||
|
||||
**Cara menggunakan:**
|
||||
|
||||
1. 🔑 Login melalui `/auth/login` endpoint
|
||||
2. 📋 Copy token dari response
|
||||
3. 🔓 Klik tombol "Authorize" di Swagger
|
||||
4. 📝 Masukkan: `Bearer <your-token>`
|
||||
5. ✅ Test semua endpoint yang tersedia
|
||||
|
||||
### 🔄 WebSocket Client Examples
|
||||
|
||||
#### JavaScript WebSocket Client
|
||||
|
||||
**Basic Connection:**
|
||||
|
||||
```javascript
|
||||
// Create WebSocket connection
|
||||
const ws = new WebSocket('ws://localhost:8080/api/v1/ws');
|
||||
|
||||
// Connection opened
|
||||
ws.onopen = function(event) {
|
||||
console.log('Connected to WebSocket server');
|
||||
};
|
||||
|
||||
// Listen for messages
|
||||
ws.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('Message received:', data);
|
||||
};
|
||||
|
||||
// Send a message
|
||||
function sendMessage(message) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'broadcast',
|
||||
message: message
|
||||
}));
|
||||
}
|
||||
|
||||
// Handle connection close
|
||||
ws.onclose = function(event) {
|
||||
console.log('Disconnected from WebSocket server');
|
||||
};
|
||||
```
|
||||
|
||||
#### Vue.js Component Example
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="websocket-chat">
|
||||
<div class="messages" ref="messages">
|
||||
<div v-for="message in messages" :key="message.id" class="message">
|
||||
<strong>{{ message.client_id }}:</strong> {{ message.content }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-area">
|
||||
<input
|
||||
v-model="newMessage"
|
||||
@keyup.enter="sendMessage"
|
||||
placeholder="Type a message..."
|
||||
>
|
||||
<button @click="sendMessage">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'WebSocketChat',
|
||||
data() {
|
||||
return {
|
||||
ws: null,
|
||||
messages: [],
|
||||
newMessage: ''
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.connectWebSocket();
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
connectWebSocket() {
|
||||
this.ws = new WebSocket('ws://localhost:8080/api/v1/ws');
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
this.handleMessage(data);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
// Auto-reconnect after 5 seconds
|
||||
setTimeout(() => this.connectWebSocket(), 5000);
|
||||
};
|
||||
},
|
||||
|
||||
sendMessage() {
|
||||
if (this.newMessage.trim() && this.ws) {
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'broadcast',
|
||||
message: this.newMessage
|
||||
}));
|
||||
|
||||
this.newMessage = '';
|
||||
}
|
||||
},
|
||||
|
||||
handleMessage(data) {
|
||||
this.messages.push({
|
||||
id: Date.now(),
|
||||
client_id: data.client_id || 'server',
|
||||
content: data.message
|
||||
});
|
||||
|
||||
// Auto-scroll to bottom
|
||||
this.$nextTick(() => {
|
||||
this.$refs.messages.scrollTop = this.$refs.messages.scrollHeight;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
### 🧪 Testing Examples
|
||||
|
||||
**WebSocket Testing with wscat:**
|
||||
|
||||
```bash
|
||||
# Install wscat globally
|
||||
npm install -g wscat
|
||||
|
||||
# Connect to WebSocket server
|
||||
wscat -c ws://localhost:8080/api/v1/ws
|
||||
|
||||
# Send a message
|
||||
{"type": "broadcast", "message": "Hello from wscat!"}
|
||||
```
|
||||
|
||||
**cURL for REST endpoints:**
|
||||
|
||||
```bash
|
||||
# Login
|
||||
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"password"}' | jq -r '.access_token')
|
||||
|
||||
# Get WebSocket statistics
|
||||
curl http://localhost:8080/api/websocket/stats
|
||||
|
||||
# Broadcast message to all clients
|
||||
curl -X POST http://localhost:8080/api/websocket/broadcast \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type": "broadcast", "message": "Server broadcast message"}'
|
||||
|
||||
# Get system information
|
||||
curl http://localhost:8080/api/sistem/info
|
||||
|
||||
# Health check
|
||||
curl http://localhost:8080/api/sistem/health
|
||||
```
|
||||
|
||||
**Response Examples:**
|
||||
|
||||
```json
|
||||
{
|
||||
"connected_clients": 5,
|
||||
"databases": ["default"],
|
||||
"database_health": "connected",
|
||||
"timestamp": 1694325600
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
***
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Masalah Umum
|
||||
|
||||
**❌ Database Connection Error**
|
||||
|
||||
```bash
|
||||
# Cek status PostgreSQL
|
||||
make db-status
|
||||
|
||||
# Reset database
|
||||
make db-reset
|
||||
|
||||
# Check logs
|
||||
make logs-db
|
||||
```
|
||||
|
||||
**❌ Generate Handler Gagal**
|
||||
|
||||
- ✅ Pastikan berada di root project
|
||||
- ✅ Cek permission write di folder `internal/`
|
||||
- ✅ Verifikasi file `internal/routes/v1/routes.go` exists
|
||||
|
||||
**❌ Token Invalid/Expired**
|
||||
|
||||
- 🔄 Login ulang untuk mendapatkan token baru
|
||||
- ⏰ Token expire dalam 1 jam (configurable)
|
||||
- 📝 Format harus: `Bearer <token>`
|
||||
|
||||
**❌ Import Error saat Generate**
|
||||
|
||||
- 🧹 Jalankan: `go mod tidy`
|
||||
- 🔄 Restart development server
|
||||
- 📝 Cek format import di generated files
|
||||
|
||||
|
||||
### Debug Mode
|
||||
|
||||
```bash
|
||||
# Enable debug logging
|
||||
export LOG_LEVEL=debug
|
||||
|
||||
# Run dengan verbose output
|
||||
make run-debug
|
||||
|
||||
# Monitor performance
|
||||
make monitor
|
||||
```
|
||||
|
||||
|
||||
***
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
### 📋 WebSocket Development Roadmap
|
||||
|
||||
- [x] ✅ **Setup environment selesai**
|
||||
- [x] ✅ **Implementasi WebSocket server**
|
||||
- [x] ✅ **Setup Vue.js client example**
|
||||
- [x] ✅ **Test WebSocket functionality**
|
||||
- [ ] 🔄 **Implementasi room management**
|
||||
- [ ] 🔄 **Tambahkan authentication ke WebSocket**
|
||||
- [ ] 🔄 **Implementasi database notifications**
|
||||
- [ ] 🔄 **Tambahkan unit tests untuk WebSocket**
|
||||
- [ ] 🔄 **Setup monitoring dan observability**
|
||||
- [ ] 🔄 **Deploy ke production**
|
||||
|
||||
### 🚀 Advanced WebSocket Features
|
||||
|
||||
- **🔄 Database Integration** - PostgreSQL LISTEN/NOTIFY
|
||||
- **📊 Real-time Analytics** - Connection metrics dan monitoring
|
||||
- **🔒 Enhanced Security** - Rate limiting, CORS, authentication
|
||||
- **📈 Performance Optimization** - Connection pooling, message queuing
|
||||
- **🏠 Multi-room Support** - Advanced room management
|
||||
- **📱 Mobile SDK Integration** - React Native, Flutter support
|
||||
- **🌐 Multi-protocol Support** - MQTT, Server-Sent Events
|
||||
- **📡 Load Balancing** - Horizontal scaling untuk WebSocket
|
||||
|
||||
### 🛠️ Immediate Next Steps
|
||||
|
||||
1. **Test WebSocket Connection**
|
||||
```bash
|
||||
# Start the server
|
||||
go run cmd/api/main.go
|
||||
|
||||
# Test with wscat
|
||||
wscat -c ws://localhost:8080/api/v1/ws
|
||||
```
|
||||
|
||||
2. **Try Vue.js Client Example**
|
||||
```bash
|
||||
cd examples/clientsocket
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. **Explore WebSocket Features**
|
||||
- Join/leave rooms
|
||||
- Send broadcast messages
|
||||
- Direct messaging between clients
|
||||
- Monitor connection statistics
|
||||
|
||||
***
|
||||
|
||||
**⚡ Total setup time: 5 menit | 🔄 WebSocket ready: Langsung test | 🌐 Client example: Vue.js included**
|
||||
|
||||
> **💡 Pro Tip:** Gunakan `make help` untuk melihat semua command yang tersedia
|
||||
|
||||
***
|
||||
|
||||
86
cmd/api/main.go
Normal file
86
cmd/api/main.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"api-service/internal/server"
|
||||
|
||||
"github.com/joho/godotenv" // Import the godotenv package
|
||||
|
||||
_ "api-service/docs"
|
||||
)
|
||||
|
||||
// @title API Service
|
||||
// @version 1.0.0
|
||||
// @description A comprehensive Go API service with Swagger documentation
|
||||
// @termsOfService http://swagger.io/terms/
|
||||
|
||||
// @contact.name API Support
|
||||
// @contact.url http://www.swagger.io/support
|
||||
// @contact.email support@swagger.io
|
||||
|
||||
// @license.name Apache 2.0
|
||||
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
|
||||
|
||||
// @host localhost:8080
|
||||
// @BasePath /api/v1
|
||||
// @schemes http https
|
||||
|
||||
func gracefulShutdown(apiServer *http.Server, done chan bool) {
|
||||
// Create context that listens for the interrupt signal from the OS.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Listen for the interrupt signal.
|
||||
<-ctx.Done()
|
||||
|
||||
log.Println("shutting down gracefully, press Ctrl+C again to force")
|
||||
stop() // Allow Ctrl+C to force shutdown
|
||||
|
||||
// The context is used to inform the server it has 5 seconds to finish
|
||||
// the request it is currently handling
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := apiServer.Shutdown(ctx); err != nil {
|
||||
log.Printf("Server forced to shutdown with error: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Server exiting")
|
||||
|
||||
// Notify the main goroutine that the shutdown is complete
|
||||
done <- true
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.Println("Starting API Service...")
|
||||
|
||||
// Load environment variables from .env file
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Printf("Warning: .env file not found or could not be loaded: %v", err)
|
||||
log.Println("Continuing with system environment variables...")
|
||||
}
|
||||
|
||||
server := server.NewServer()
|
||||
|
||||
// Create a done channel to signal when the shutdown is complete
|
||||
done := make(chan bool, 1)
|
||||
|
||||
// Run graceful shutdown in a separate goroutine
|
||||
go gracefulShutdown(server, done)
|
||||
|
||||
log.Printf("Server starting on port %s", server.Addr)
|
||||
err := server.ListenAndServe()
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
panic(fmt.Sprintf("http server error: %s", err))
|
||||
}
|
||||
|
||||
// Wait for the graceful shutdown to complete
|
||||
<-done
|
||||
log.Println("Graceful shutdown complete.")
|
||||
}
|
||||
109
cmd/logging/main.go
Normal file
109
cmd/logging/main.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"api-service/pkg/logger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("Testing Dynamic Logging Functions...")
|
||||
fmt.Println("====================================")
|
||||
|
||||
// Test fungsi penyimpanan log dinamis
|
||||
testDynamicLogging()
|
||||
|
||||
// Tunggu sebentar untuk memastikan goroutine selesai
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
fmt.Println("\n====================================")
|
||||
fmt.Println("Dynamic logging test completed!")
|
||||
fmt.Println("Check the log files in pkg/logger/data/ directory")
|
||||
}
|
||||
|
||||
func testDynamicLogging() {
|
||||
// Buat logger instance
|
||||
loggerInstance := logger.New("test-app", logger.DEBUG, false)
|
||||
|
||||
// Test 1: Log dengan penyimpanan otomatis
|
||||
fmt.Println("\n1. Testing automatic log saving...")
|
||||
loggerInstance.LogAndSave(logger.INFO, "Application started successfully", map[string]interface{}{
|
||||
"version": "1.0.0",
|
||||
"build_date": time.Now().Format("2006-01-02"),
|
||||
"environment": "development",
|
||||
})
|
||||
|
||||
// Test 2: Log dengan request context
|
||||
fmt.Println("\n2. Testing log with request context...")
|
||||
requestLogger := loggerInstance.WithRequestID("req-001").WithCorrelationID("corr-001")
|
||||
requestLogger.LogAndSave(logger.INFO, "User login attempt", map[string]interface{}{
|
||||
"username": "john_doe",
|
||||
"ip": "192.168.1.100",
|
||||
"success": true,
|
||||
})
|
||||
|
||||
// Test 3: Error logging
|
||||
fmt.Println("\n3. Testing error logging...")
|
||||
loggerInstance.LogAndSave(logger.ERROR, "Database connection failed", map[string]interface{}{
|
||||
"error": "connection timeout",
|
||||
"retry_count": 3,
|
||||
"host": "db.example.com:5432",
|
||||
})
|
||||
|
||||
// Test 4: Manual log entry saving
|
||||
fmt.Println("\n4. Testing manual log entry saving...")
|
||||
manualEntry := logger.LogEntry{
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
Level: "DEBUG",
|
||||
Service: "manual-test",
|
||||
Message: "Manual log entry created",
|
||||
RequestID: "manual-req-001",
|
||||
CorrelationID: "manual-corr-001",
|
||||
File: "main.go",
|
||||
Line: 42,
|
||||
Fields: map[string]interface{}{
|
||||
"custom_field": "test_value",
|
||||
"number": 123,
|
||||
"active": true,
|
||||
},
|
||||
}
|
||||
|
||||
// Simpan manual ke berbagai format
|
||||
if err := logger.SaveLogText(manualEntry); err != nil {
|
||||
log.Printf("Error saving text log: %v", err)
|
||||
} else {
|
||||
fmt.Println("✓ Text log saved successfully")
|
||||
}
|
||||
|
||||
if err := logger.SaveLogJSON(manualEntry); err != nil {
|
||||
log.Printf("Error saving JSON log: %v", err)
|
||||
} else {
|
||||
fmt.Println("✓ JSON log saved successfully")
|
||||
}
|
||||
|
||||
if err := logger.SaveLogToDatabase(manualEntry); err != nil {
|
||||
log.Printf("Error saving database log: %v", err)
|
||||
} else {
|
||||
fmt.Println("✓ Database log saved successfully")
|
||||
}
|
||||
|
||||
// Test 5: Performance logging dengan durasi
|
||||
fmt.Println("\n5. Testing performance logging...")
|
||||
start := time.Now()
|
||||
|
||||
// Simulasi proses yang memakan waktu
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
duration := time.Since(start)
|
||||
loggerInstance.LogAndSave(logger.INFO, "Data processing completed", map[string]interface{}{
|
||||
"operation": "data_import",
|
||||
"duration": duration.String(),
|
||||
"duration_ms": duration.Milliseconds(),
|
||||
"records": 1000,
|
||||
"throughput": fmt.Sprintf("%.2f records/ms", 1000/float64(duration.Milliseconds())),
|
||||
})
|
||||
|
||||
fmt.Println("\n✓ All logging tests completed successfully!")
|
||||
}
|
||||
130
diagnostic/main.go
Normal file
130
diagnostic/main.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== Database Connection Diagnostic Tool ===")
|
||||
|
||||
// Load environment variables from .env file
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Printf("Warning: Error loading .env file: %v", err)
|
||||
}
|
||||
|
||||
// Get configuration from environment
|
||||
host := os.Getenv("DB_HOST")
|
||||
port := os.Getenv("DB_PORT")
|
||||
username := os.Getenv("DB_USERNAME")
|
||||
password := os.Getenv("DB_PASSWORD")
|
||||
database := os.Getenv("DB_DATABASE")
|
||||
sslmode := os.Getenv("DB_SSLMODE")
|
||||
|
||||
if sslmode == "" {
|
||||
sslmode = "disable"
|
||||
}
|
||||
|
||||
fmt.Printf("Host: %s\n", host)
|
||||
fmt.Printf("Port: %s\n", port)
|
||||
fmt.Printf("Username: %s\n", username)
|
||||
fmt.Printf("Database: %s\n", database)
|
||||
fmt.Printf("SSL Mode: %s\n", sslmode)
|
||||
|
||||
if host == "" || username == "" || password == "" {
|
||||
fmt.Println("❌ Missing required environment variables")
|
||||
return
|
||||
}
|
||||
|
||||
// Test connection to PostgreSQL server
|
||||
fmt.Println("\n--- Testing PostgreSQL Server Connection ---")
|
||||
serverConnStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=%s",
|
||||
host, port, username, password, sslmode)
|
||||
|
||||
db, err := sql.Open("pgx", serverConnStr)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to connect to PostgreSQL server: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
err = db.Ping()
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to ping PostgreSQL server: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("✅ Successfully connected to PostgreSQL server")
|
||||
|
||||
// Check if database exists
|
||||
fmt.Println("\n--- Checking Database Existence ---")
|
||||
var exists bool
|
||||
err = db.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", database).Scan(&exists)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to check database existence: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !exists {
|
||||
fmt.Printf("❌ Database '%s' does not exist\n", database)
|
||||
|
||||
// List available databases
|
||||
fmt.Println("\n--- Available Databases ---")
|
||||
rows, err := db.Query("SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname")
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to list databases: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
fmt.Println("Available databases:")
|
||||
for rows.Next() {
|
||||
var dbName string
|
||||
if err := rows.Scan(&dbName); err != nil {
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" - %s\n", dbName)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Database '%s' exists\n", database)
|
||||
|
||||
// Test direct connection to the database
|
||||
fmt.Println("\n--- Testing Direct Database Connection ---")
|
||||
directConnStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
|
||||
host, port, username, password, database, sslmode)
|
||||
|
||||
targetDB, err := sql.Open("pgx", directConnStr)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to connect to database '%s': %v\n", database, err)
|
||||
return
|
||||
}
|
||||
defer targetDB.Close()
|
||||
|
||||
err = targetDB.Ping()
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to ping database '%s': %v\n", database, err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Successfully connected to database '%s'\n", database)
|
||||
|
||||
// Test basic query
|
||||
fmt.Println("\n--- Testing Basic Query ---")
|
||||
var version string
|
||||
err = targetDB.QueryRow("SELECT version()").Scan(&version)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to execute query: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("✅ PostgreSQL Version: %s\n", version)
|
||||
|
||||
fmt.Println("\n🎉 All tests passed! Database connection is working correctly.")
|
||||
}
|
||||
194
docker-compose.yml
Normal file
194
docker-compose.yml
Normal file
@@ -0,0 +1,194 @@
|
||||
services:
|
||||
# # PostgreSQL Database
|
||||
# psql_bp:
|
||||
# image: postgres:15-alpine
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# POSTGRES_USER: stim
|
||||
# POSTGRES_PASSWORD: stim*RS54
|
||||
# POSTGRES_DB: satu_db
|
||||
# ports:
|
||||
# - "5432:5432"
|
||||
# volumes:
|
||||
# - postgres_data:/var/lib/postgresql/data
|
||||
# healthcheck:
|
||||
# test: ["CMD-SHELL", "pg_isready -U stim -d satu_db"]
|
||||
# interval: 10s
|
||||
# timeout: 5s
|
||||
# retries: 5
|
||||
# networks:
|
||||
# - blueprint
|
||||
|
||||
# # MongoDB Database
|
||||
# mongodb:
|
||||
# image: mongo:7-jammy
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# MONGO_INITDB_ROOT_USERNAME: admin
|
||||
# MONGO_INITDB_ROOT_PASSWORD: stim*rs54
|
||||
# ports:
|
||||
# - "27017:27017"
|
||||
# volumes:
|
||||
# - mongodb_data:/data/db
|
||||
# networks:
|
||||
# - blueprint
|
||||
|
||||
# # MySQL Antrian Database
|
||||
# mysql_antrian:
|
||||
# image: mysql:8.0
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# MYSQL_ROOT_PASSWORD: www-data
|
||||
# MYSQL_USER: www-data
|
||||
# MYSQL_PASSWORD: www-data
|
||||
# MYSQL_DATABASE: antrian_rssa
|
||||
# ports:
|
||||
# - "3306:3306"
|
||||
# volumes:
|
||||
# - mysql_antrian_data:/var/lib/mysql
|
||||
# healthcheck:
|
||||
# test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
# interval: 10s
|
||||
# timeout: 5s
|
||||
# retries: 5
|
||||
# networks:
|
||||
# - blueprint
|
||||
|
||||
# # MySQL Medical Database
|
||||
# mysql_medical:
|
||||
# image: mysql:8.0
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# MYSQL_ROOT_PASSWORD: meninjar*RS54
|
||||
# MYSQL_USER: meninjardev
|
||||
# MYSQL_PASSWORD: meninjar*RS54
|
||||
# MYSQL_DATABASE: healtcare_database
|
||||
# ports:
|
||||
# - "3307:3306"
|
||||
# volumes:
|
||||
# - mysql_medical_data:/var/lib/mysql
|
||||
# healthcheck:
|
||||
# test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
# interval: 10s
|
||||
# timeout: 5s
|
||||
# retries: 5
|
||||
# networks:
|
||||
# - blueprint
|
||||
|
||||
# Main Application
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: prod
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
# Server Configuration
|
||||
APP_ENV: production
|
||||
PORT: 8080
|
||||
GIN_MODE: release
|
||||
|
||||
# Default Database Configuration (PostgreSQL)
|
||||
DB_CONNECTION: postgres
|
||||
DB_USERNAME: stim
|
||||
DB_PASSWORD: stim*RS54
|
||||
DB_HOST: 10.10.123.165
|
||||
DB_DATABASE: satu_db
|
||||
DB_PORT: 5432
|
||||
DB_SSLMODE: disable
|
||||
|
||||
# satudata Database Configuration (PostgreSQL)
|
||||
POSTGRES_SATUDATA_CONNECTION: postgres
|
||||
POSTGRES_SATUDATA_USERNAME: stim
|
||||
POSTGRES_SATUDATA_PASSWORD: stim*RS54
|
||||
POSTGRES_SATUDATA_HOST: 10.10.123.165
|
||||
POSTGRES_SATUDATA_DATABASE: satu_db
|
||||
POSTGRES_SATUDATA_PORT: 5432
|
||||
POSTGRES_SATUDATA_SSLMODE: disable
|
||||
|
||||
# Mongo Database
|
||||
MONGODB_MONGOHL7_CONNECTION: mongodb
|
||||
MONGODB_MONGOHL7_HOST: 10.10.123.206
|
||||
MONGODB_MONGOHL7_PORT: 27017
|
||||
MONGODB_MONGOHL7_USER: admin
|
||||
MONGODB_MONGOHL7_PASS: stim*rs54
|
||||
MONGODB_MONGOHL7_MASTER: master
|
||||
MONGODB_MONGOHL7_LOCAL: local
|
||||
MONGODB_MONGOHL7_SSLMODE: disable
|
||||
|
||||
# MYSQL Antrian Database
|
||||
# MYSQL_ANTRIAN_CONNECTION: mysql
|
||||
# MYSQL_ANTRIAN_HOST: mysql_antrian
|
||||
# MYSQL_ANTRIAN_USERNAME: www-data
|
||||
# MYSQL_ANTRIAN_PASSWORD: www-data
|
||||
# MYSQL_ANTRIAN_DATABASE: antrian_rssa
|
||||
# MYSQL_ANTRIAN_PORT: 3306
|
||||
# MYSQL_ANTRIAN_SSLMODE: disable
|
||||
|
||||
# MYSQL Medical Database
|
||||
MYSQL_MEDICAL_CONNECTION: mysql
|
||||
MYSQL_MEDICAL_HOST: 10.10.123.163
|
||||
MYSQL_MEDICAL_USERNAME: meninjardev
|
||||
MYSQL_MEDICAL_PASSWORD: meninjar*RS54
|
||||
MYSQL_MEDICAL_DATABASE: healtcare_database
|
||||
MYSQL_MEDICAL_PORT: 3306
|
||||
MYSQL_MEDICAL_SSLMODE: disable
|
||||
|
||||
# Keycloak Configuration
|
||||
KEYCLOAK_ISSUER: https://auth.rssa.top/realms/sandbox
|
||||
KEYCLOAK_AUDIENCE: nuxtsim-pendaftaran
|
||||
KEYCLOAK_JWKS_URL: https://auth.rssa.top/realms/sandbox/protocol/openid-connect/certs
|
||||
KEYCLOAK_ENABLED: true
|
||||
|
||||
# BPJS Configuration
|
||||
BPJS_BASEURL: https://apijkn.bpjs-kesehatan.go.id/vclaim-rest
|
||||
BPJS_CONSID: 5257
|
||||
BPJS_USERKEY: 4cf1cbef8c008440bbe9ef9ba789e482
|
||||
BPJS_SECRETKEY: 1bV363512D
|
||||
|
||||
# SatuSehat Configuration
|
||||
BRIDGING_SATUSEHAT_ORG_ID: 100026555
|
||||
BRIDGING_SATUSEHAT_FASYAKES_ID: 3573011
|
||||
BRIDGING_SATUSEHAT_CLIENT_ID: l1ZgJGW6K5pnrqGUikWM7fgIoquA2AQ5UUG0U8WqHaq2VEyZ
|
||||
BRIDGING_SATUSEHAT_CLIENT_SECRET: Al3PTYAW6axPiAFwaFlpn8qShLFW5YGMgG8w1qhexgCc7lGTEjjcR6zxa06ThPDy
|
||||
BRIDGING_SATUSEHAT_AUTH_URL: https://api-satusehat.kemkes.go.id/oauth2/v1
|
||||
BRIDGING_SATUSEHAT_BASE_URL: https://api-satusehat.kemkes.go.id/fhir-r4/v1
|
||||
BRIDGING_SATUSEHAT_CONSENT_URL: https://api-satusehat.dto.kemkes.go.id/consent/v1
|
||||
BRIDGING_SATUSEHAT_KFA_URL: https://api-satusehat.kemkes.go.id/kfa-v2
|
||||
|
||||
# Swagger Configuration
|
||||
SWAGGER_TITLE: My Custom API Service
|
||||
SWAGGER_DESCRIPTION: This is a custom API service for managing various resources
|
||||
SWAGGER_VERSION: 2.0.0
|
||||
SWAGGER_CONTACT_NAME: Support Team
|
||||
SWAGGER_HOST: api.mycompany.com:8080
|
||||
SWAGGER_BASE_PATH: /api/v2
|
||||
SWAGGER_SCHEMES: https
|
||||
|
||||
# API Configuration
|
||||
API_TITLE: API Service UJICOBA
|
||||
API_DESCRIPTION: Dokumentation SWAGGER
|
||||
API_VERSION: 3.0.0
|
||||
|
||||
# depends_on:
|
||||
# psql_bp:
|
||||
# condition: service_healthy
|
||||
# mongodb:
|
||||
# condition: service_started
|
||||
# mysql_antrian:
|
||||
# condition: service_healthy
|
||||
# mysql_medical:
|
||||
# condition: service_healthy
|
||||
networks:
|
||||
- goservice
|
||||
|
||||
# volumes:
|
||||
# postgres_data:
|
||||
# mongodb_data:
|
||||
# mysql_antrian_data:
|
||||
# mysql_medical_data:
|
||||
|
||||
networks:
|
||||
goservice:
|
||||
2238
docs/docs.go
Normal file
2238
docs/docs.go
Normal file
File diff suppressed because it is too large
Load Diff
2218
docs/swagger.json
Normal file
2218
docs/swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
1464
docs/swagger.yaml
Normal file
1464
docs/swagger.yaml
Normal file
File diff suppressed because it is too large
Load Diff
92
example.env
Normal file
92
example.env
Normal file
@@ -0,0 +1,92 @@
|
||||
# Server Configuration
|
||||
PORT=8080
|
||||
GIN_MODE=debug
|
||||
|
||||
# Default Database Configuration (PostgreSQL)
|
||||
DB_CONNECTION=postgres
|
||||
DB_USERNAME=stim
|
||||
DB_PASSWORD=stim*RS54
|
||||
DB_HOST=10.10.123.165
|
||||
DB_DATABASE=satu_db
|
||||
DB_PORT=5000
|
||||
DB_SSLMODE=disable
|
||||
|
||||
# satudata Database Configuration (PostgreSQL)
|
||||
# POSTGRES_CONNECTION=postgres
|
||||
# POSTGRES_USERNAME=stim
|
||||
# POSTGRES_PASSWORD=stim*RS54
|
||||
# POSTGRES_HOST=10.10.123.165
|
||||
# POSTGRES_DATABASE=satu_db
|
||||
# POSTGRES_NAME=satu_db
|
||||
# POSTGRES_PORT=5000
|
||||
# POSTGRES_SSLMODE=disable
|
||||
|
||||
|
||||
POSTGRES_SATUDATA_CONNECTION=postgres
|
||||
POSTGRES_SATUDATA_USERNAME=stim
|
||||
POSTGRES_SATUDATA_PASSWORD=stim*RS54
|
||||
POSTGRES_SATUDATA_HOST=10.10.123.165
|
||||
POSTGRES_SATUDATA_DATABASE=satu_db
|
||||
POSTGRES_SATUDATA_PORT=5000
|
||||
POSTGRES_SATUDATA_SSLMODE=disable
|
||||
|
||||
# Mongo Database
|
||||
MONGODB_MONGOHL7_CONNECTION=mongodb
|
||||
MONGODB_MONGOHL7_HOST=10.10.123.206
|
||||
MONGODB_MONGOHL7_PORT=27017
|
||||
MONGODB_MONGOHL7_USER=admin
|
||||
MONGODB_MONGOHL7_PASS=stim*rs54
|
||||
MONGODB_MONGOHL7_MASTER=master
|
||||
MONGODB_MONGOHL7_LOCAL=local
|
||||
MONGODB_MONGOHL7_SSLMODE=disable
|
||||
|
||||
# MYSQL Antrian Database
|
||||
MYSQL_ANTRIAN_CONNECTION=mysql
|
||||
MYSQL_ANTRIAN_HOST=10.10.123.163
|
||||
MYSQL_ANTRIAN_USERNAME=www-data
|
||||
MYSQL_ANTRIAN_PASSWORD=www-data
|
||||
MYSQL_ANTRIAN_DATABASE=antrian_rssa
|
||||
MYSQL_ANTRIAN_PORT=3306
|
||||
MYSQL_ANTRIAN_SSLMODE=disable
|
||||
|
||||
|
||||
MYSQL_MEDICAL_CONNECTION=mysql
|
||||
MYSQL_MEDICAL_HOST=10.10.123.147
|
||||
MYSQL_MEDICAL_USERNAME=meninjardev
|
||||
MYSQL_MEDICAL_PASSWORD=meninjar*RS54
|
||||
MYSQL_MEDICAL_DATABASE=healtcare_database
|
||||
MYSQL_MEDICAL_PORT=3306
|
||||
MYSQL_MEDICAL_SSLMODE=disable
|
||||
|
||||
# Keycloak Configuration (optional)
|
||||
KEYCLOAK_ISSUER=https://auth.rssa.top/realms/sandbox
|
||||
KEYCLOAK_AUDIENCE=nuxtsim-pendaftaran
|
||||
KEYCLOAK_JWKS_URL=https://auth.rssa.top/realms/sandbox/protocol/openid-connect/certs
|
||||
KEYCLOAK_ENABLED=true
|
||||
|
||||
# BPJS Configuration
|
||||
BPJS_BASEURL=https://apijkn.bpjs-kesehatan.go.id/vclaim-rest
|
||||
BPJS_CONSID=5257
|
||||
BPJS_USERKEY=4cf1cbef8c008440bbe9ef9ba789e482
|
||||
BPJS_SECRETKEY=1bV363512D
|
||||
|
||||
BRIDGING_SATUSEHAT_ORG_ID=100026555
|
||||
BRIDGING_SATUSEHAT_FASYAKES_ID=3573011
|
||||
BRIDGING_SATUSEHAT_CLIENT_ID=l1ZgJGW6K5pnrqGUikWM7fgIoquA2AQ5UUG0U8WqHaq2VEyZ
|
||||
BRIDGING_SATUSEHAT_CLIENT_SECRET=Al3PTYAW6axPiAFwaFlpn8qShLFW5YGMgG8w1qhexgCc7lGTEjjcR6zxa06ThPDy
|
||||
BRIDGING_SATUSEHAT_AUTH_URL=https://api-satusehat.kemkes.go.id/oauth2/v1
|
||||
BRIDGING_SATUSEHAT_BASE_URL=https://api-satusehat.kemkes.go.id/fhir-r4/v1
|
||||
BRIDGING_SATUSEHAT_CONSENT_URL=https://api-satusehat.dto.kemkes.go.id/consent/v1
|
||||
BRIDGING_SATUSEHAT_KFA_URL=https://api-satusehat.kemkes.go.id/kfa-v2
|
||||
|
||||
SWAGGER_TITLE=My Custom API Service
|
||||
SWAGGER_DESCRIPTION=This is a custom API service for managing various resources
|
||||
SWAGGER_VERSION=2.0.0
|
||||
SWAGGER_CONTACT_NAME=STIM IT Support
|
||||
SWAGGER_HOST=api.mycompany.com:8080
|
||||
SWAGGER_BASE_PATH=/api/v2
|
||||
SWAGGER_SCHEMES=https
|
||||
|
||||
API_TITLE=API Service UJICOBA
|
||||
API_DESCRIPTION=Dokumentation SWAGGER
|
||||
API_VERSION=3.0.0
|
||||
24
examples/clientsocket/.gitignore
vendored
Normal file
24
examples/clientsocket/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
75
examples/clientsocket/README.md
Normal file
75
examples/clientsocket/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Nuxt Minimal Starter
|
||||
|
||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install dependencies:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
6
examples/clientsocket/app/app.vue
Normal file
6
examples/clientsocket/app/app.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtWelcome />
|
||||
</div>
|
||||
</template>
|
||||
672
examples/clientsocket/assets/css/main.css
Normal file
672
examples/clientsocket/assets/css/main.css
Normal file
@@ -0,0 +1,672 @@
|
||||
/* Enhanced WebSocket Client Styles */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
padding: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-connected {
|
||||
background: #4CAF50;
|
||||
box-shadow: 0 0 8px rgba(76, 175, 80, 0.6);
|
||||
}
|
||||
|
||||
.status-connecting {
|
||||
background: #FFC107;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.status-disconnected {
|
||||
background: #F44336;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.health-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.health-excellent {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.health-good {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.health-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.health-poor {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
margin-bottom: 24px;
|
||||
overflow-x: auto;
|
||||
background: white;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 16px 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: #f8f9fa;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
border-bottom-color: #1976d2;
|
||||
color: #1976d2;
|
||||
font-weight: 600;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
padding: 24px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-group input,
|
||||
.input-group select,
|
||||
.input-group textarea {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.input-group input:focus,
|
||||
.input-group select:focus,
|
||||
.input-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #1976d2;
|
||||
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.1);
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
margin: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #1565c0;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.3);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #FFC107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #e0a800;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
background: #17a2b8;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-info:hover {
|
||||
background: #138496;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(23, 162, 184, 0.3);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #218838;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
height: 500px;
|
||||
overflow-y: auto;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background: #fafafa;
|
||||
font-family: "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #1976d2;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateX(-20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.message.error {
|
||||
border-left-color: #dc3545;
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.message.warning {
|
||||
border-left-color: #FFC107;
|
||||
background: #fffbf0;
|
||||
}
|
||||
|
||||
.message.info {
|
||||
border-left-color: #17a2b8;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
border-left-color: #28a745;
|
||||
background: #f0fff4;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message-type {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2.5em;
|
||||
font-weight: 700;
|
||||
color: #1976d2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.online-users {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.online-users::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.online-users::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.online-users::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.user-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
margin: 8px 0;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.user-id {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #f3f3f3;
|
||||
border-top: 2px solid #1976d2;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.message-limit-warning {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
padding: 16px;
|
||||
margin: 16px 0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: #856404;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-controls {
|
||||
background: #fff3cd;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #ffeaa7;
|
||||
}
|
||||
|
||||
.admin-warning {
|
||||
color: #856404;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
border: 1px solid #e9ecef;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #f8f9fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message code {
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
margin: 10px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
height: 300px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
height: 250px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #121212;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.controls {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #404040;
|
||||
}
|
||||
|
||||
.message {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #404040;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #404040;
|
||||
}
|
||||
|
||||
.online-users {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #404040;
|
||||
}
|
||||
|
||||
.user-item {
|
||||
background: #333;
|
||||
border: 1px solid #404040;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
background: #1a1a1a;
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #2a2a2a;
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #333;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility improvements */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus styles for keyboard navigation */
|
||||
.btn:focus,
|
||||
.tab:focus,
|
||||
.input-group input:focus,
|
||||
.input-group select:focus,
|
||||
.input-group textarea:focus {
|
||||
outline: 2px solid #1976d2;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
.btn {
|
||||
border: 2px solid currentColor;
|
||||
}
|
||||
|
||||
.message {
|
||||
border: 2px solid currentColor;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border: 2px solid currentColor;
|
||||
}
|
||||
}
|
||||
2251
examples/clientsocket/client.html
Normal file
2251
examples/clientsocket/client.html
Normal file
File diff suppressed because it is too large
Load Diff
367
examples/clientsocket/components/WebSocketClient.vue
Normal file
367
examples/clientsocket/components/WebSocketClient.vue
Normal file
@@ -0,0 +1,367 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<h1>WebSocket Client</h1>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div class="status-bar">
|
||||
<div class="status-item">
|
||||
<div
|
||||
class="status-indicator"
|
||||
:class="connectionStatusClass"
|
||||
></div>
|
||||
<span>{{ connectionStatusText }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>Client ID:</span>
|
||||
<strong>{{ connectionState?.clientId || 'Not connected' }}</strong>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>Room:</span>
|
||||
<strong>{{ connectionState?.currentRoom || 'None' }}</strong>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>Messages:</span>
|
||||
<strong>{{ messages?.length || 0 }}/{{ config?.maxMessages || 1000 }}</strong>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>Uptime:</span>
|
||||
<strong>{{ connectionState?.uptime || '00:00:00' }}</strong>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>Health:</span>
|
||||
<div
|
||||
class="health-indicator"
|
||||
:class="connectionHealthClass"
|
||||
>
|
||||
{{ connectionHealthText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message Limit Warning -->
|
||||
<div v-if="shouldShowMessageWarning" class="message-limit-warning">
|
||||
<v-icon>mdi-alert</v-icon>
|
||||
Message limit approaching ({{ messages?.length || 0 }}/{{ config?.maxMessages || 1000 }})
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'connection' }"
|
||||
@click="activeTab = 'connection'"
|
||||
>
|
||||
Connection
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'messaging' }"
|
||||
@click="activeTab = 'messaging'"
|
||||
>
|
||||
Messaging
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'database' }"
|
||||
@click="activeTab = 'database'"
|
||||
>
|
||||
Database
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'monitoring' }"
|
||||
@click="activeTab = 'monitoring'"
|
||||
>
|
||||
Monitoring
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'admin' }"
|
||||
@click="activeTab = 'admin'"
|
||||
>
|
||||
Admin
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content" :class="{ active: activeTab === 'connection' }">
|
||||
<ConnectionTab />
|
||||
</div>
|
||||
|
||||
<div class="tab-content" :class="{ active: activeTab === 'messaging' }">
|
||||
<MessagingTab />
|
||||
</div>
|
||||
|
||||
<div class="tab-content" :class="{ active: activeTab === 'database' }">
|
||||
<DatabaseTab />
|
||||
</div>
|
||||
|
||||
<div class="tab-content" :class="{ active: activeTab === 'monitoring' }">
|
||||
<MonitoringTab />
|
||||
</div>
|
||||
|
||||
<div class="tab-content" :class="{ active: activeTab === 'admin' }">
|
||||
<AdminTab />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useWebSocket } from '~/composables/useWebSocket'
|
||||
import ConnectionTab from './tabs/ConnectionTab.vue'
|
||||
import MessagingTab from './tabs/MessagingTab.vue'
|
||||
import DatabaseTab from './tabs/DatabaseTab.vue'
|
||||
import MonitoringTab from './tabs/MonitoringTab.vue'
|
||||
import AdminTab from './tabs/AdminTab.vue'
|
||||
|
||||
const activeTab = ref('connection')
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
connectionStatus,
|
||||
connectionState,
|
||||
config,
|
||||
messages,
|
||||
stats,
|
||||
onlineUsers,
|
||||
activityLog,
|
||||
connect,
|
||||
disconnect,
|
||||
cleanup,
|
||||
isMessageLimitReached,
|
||||
shouldShowMessageWarning,
|
||||
connectionHealthColor,
|
||||
connectionHealthText
|
||||
} = useWebSocket()
|
||||
|
||||
const connectionStatusClass = computed(() => {
|
||||
switch (connectionStatus.value) {
|
||||
case 'connected': return 'status-connected'
|
||||
case 'connecting': return 'status-connecting'
|
||||
case 'disconnected': return 'status-disconnected'
|
||||
default: return 'status-disconnected'
|
||||
}
|
||||
})
|
||||
|
||||
const connectionStatusText = computed(() => {
|
||||
switch (connectionStatus.value) {
|
||||
case 'connected': return 'Connected'
|
||||
case 'connecting': return 'Connecting...'
|
||||
case 'disconnected': return 'Disconnected'
|
||||
case 'error': return 'Error'
|
||||
default: return 'Unknown'
|
||||
}
|
||||
})
|
||||
|
||||
const connectionHealthClass = computed(() => {
|
||||
switch (connectionState?.connectionHealth) {
|
||||
case 'excellent': return 'health-excellent'
|
||||
case 'good': return 'health-good'
|
||||
case 'warning': return 'health-warning'
|
||||
case 'poor': return 'health-poor'
|
||||
default: return 'health-poor'
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Auto-connect on mount
|
||||
connect()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
padding: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-connected {
|
||||
background: #4CAF50;
|
||||
box-shadow: 0 0 8px rgba(76, 175, 80, 0.6);
|
||||
}
|
||||
|
||||
.status-connecting {
|
||||
background: #FFC107;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.status-disconnected {
|
||||
background: #F44336;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.health-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.health-excellent {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.health-good {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.health-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.health-poor {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
margin-bottom: 24px;
|
||||
overflow-x: auto;
|
||||
background: white;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 16px 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: #f8f9fa;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
border-bottom-color: #1976d2;
|
||||
color: #1976d2;
|
||||
font-weight: 600;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.message-limit-warning {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
padding: 16px;
|
||||
margin: 16px 0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: #856404;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
margin: 10px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
665
examples/clientsocket/components/tabs/AdminTab.vue
Normal file
665
examples/clientsocket/components/tabs/AdminTab.vue
Normal file
@@ -0,0 +1,665 @@
|
||||
<template>
|
||||
<div class="admin-tab">
|
||||
<div class="controls">
|
||||
<div class="input-group">
|
||||
<label for="adminCommand">Admin Command</label>
|
||||
<select id="adminCommand" v-model="adminCommand">
|
||||
<option value="restart_server">Restart Server</option>
|
||||
<option value="shutdown_server">Shutdown Server</option>
|
||||
<option value="clear_cache">Clear Cache</option>
|
||||
<option value="reload_config">Reload Configuration</option>
|
||||
<option value="backup_database">Backup Database</option>
|
||||
<option value="restore_database">Restore Database</option>
|
||||
<option value="export_logs">Export Logs</option>
|
||||
<option value="clear_logs">Clear Logs</option>
|
||||
<option value="update_permissions">Update Permissions</option>
|
||||
<option value="custom">Custom Command</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="commandParams">Command Parameters (JSON)</label>
|
||||
<textarea
|
||||
id="commandParams"
|
||||
v-model="commandParams"
|
||||
placeholder='{"target": "all", "force": false}'
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="customCommand">Custom Command</label>
|
||||
<input
|
||||
id="customCommand"
|
||||
v-model="customCommand"
|
||||
type="text"
|
||||
placeholder="custom_admin_command"
|
||||
:disabled="adminCommand !== 'custom'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="confirmationCode">Confirmation Code</label>
|
||||
<input
|
||||
id="confirmationCode"
|
||||
v-model="confirmationCode"
|
||||
type="text"
|
||||
placeholder="Enter confirmation code"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
:disabled="!isConnected || !confirmationCode.trim()"
|
||||
@click="executeAdminCommand"
|
||||
>
|
||||
Execute Command
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
:disabled="!isConnected"
|
||||
@click="getServerInfo"
|
||||
>
|
||||
Get Server Info
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
:disabled="!isConnected"
|
||||
@click="getSystemHealth"
|
||||
>
|
||||
Get System Health
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-warning"
|
||||
@click="clearCommandHistory"
|
||||
>
|
||||
Clear History
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Server Information -->
|
||||
<div class="server-info-section" v-if="serverInfo">
|
||||
<h3>Server Information</h3>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<div class="info-label">Server Version</div>
|
||||
<div class="info-value">{{ serverInfo.version }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Environment</div>
|
||||
<div class="info-value">{{ serverInfo.environment }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Node Version</div>
|
||||
<div class="info-value">{{ serverInfo.nodeVersion }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Platform</div>
|
||||
<div class="info-value">{{ serverInfo.platform }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Architecture</div>
|
||||
<div class="info-value">{{ serverInfo.architecture }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">CPU Cores</div>
|
||||
<div class="info-value">{{ serverInfo.cpuCores }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Memory Usage</div>
|
||||
<div class="info-value">{{ formatBytes(serverInfo.memoryUsage) }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Uptime</div>
|
||||
<div class="info-value">{{ formatUptime(serverInfo.uptime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Health -->
|
||||
<div class="system-health-section" v-if="systemHealth">
|
||||
<h3>System Health</h3>
|
||||
<div class="health-grid">
|
||||
<div class="health-item">
|
||||
<div class="health-label">CPU Usage</div>
|
||||
<div class="health-value">{{ systemHealth.cpuUsage }}%</div>
|
||||
<div class="health-bar">
|
||||
<div
|
||||
class="health-bar-fill"
|
||||
:style="{ width: systemHealth.cpuUsage + '%' }"
|
||||
:class="getCpuUsageClass(systemHealth.cpuUsage)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<div class="health-label">Memory Usage</div>
|
||||
<div class="health-value">{{ systemHealth.memoryUsage }}%</div>
|
||||
<div class="health-bar">
|
||||
<div
|
||||
class="health-bar-fill"
|
||||
:style="{ width: systemHealth.memoryUsage + '%' }"
|
||||
:class="getMemoryUsageClass(systemHealth.memoryUsage)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<div class="health-label">Disk Usage</div>
|
||||
<div class="health-value">{{ systemHealth.diskUsage }}%</div>
|
||||
<div class="health-bar">
|
||||
<div
|
||||
class="health-bar-fill"
|
||||
:style="{ width: systemHealth.diskUsage + '%' }"
|
||||
:class="getDiskUsageClass(systemHealth.diskUsage)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<div class="health-label">Network I/O</div>
|
||||
<div class="health-value">{{ formatBytes(systemHealth.networkRx) }} / {{ formatBytes(systemHealth.networkTx) }}</div>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<div class="health-label">Active Connections</div>
|
||||
<div class="health-value">{{ systemHealth.activeConnections }}</div>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<div class="health-label">Error Rate</div>
|
||||
<div class="health-value">{{ systemHealth.errorRate }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Command History -->
|
||||
<div class="command-history-section">
|
||||
<h3>Command History</h3>
|
||||
<div class="command-history">
|
||||
<div
|
||||
v-for="(command, index) in commandHistory.slice(0, 10)"
|
||||
:key="index"
|
||||
class="command-item"
|
||||
>
|
||||
<div class="command-header">
|
||||
<div class="command-type">{{ command.command }}</div>
|
||||
<div class="command-time">{{ formatTime(command.timestamp) }}</div>
|
||||
<div class="command-status" :class="command.success ? 'success' : 'error'">
|
||||
{{ command.success ? 'Success' : 'Error' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="command-details">
|
||||
<div v-if="command.params" class="command-params">
|
||||
<strong>Parameters:</strong> {{ command.params }}
|
||||
</div>
|
||||
<div v-if="command.result" class="command-result">
|
||||
<strong>Result:</strong>
|
||||
<pre>{{ JSON.stringify(command.result, null, 2) }}</pre>
|
||||
</div>
|
||||
<div v-if="command.error" class="command-error">
|
||||
<strong>Error:</strong> {{ command.error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="commandHistory.length === 0" class="no-commands">
|
||||
No commands executed yet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security Notice -->
|
||||
<div class="security-notice">
|
||||
<h4>⚠️ Security Notice</h4>
|
||||
<p>Admin commands can have serious consequences. Please ensure you have proper authorization and understand the impact of each command before execution.</p>
|
||||
<ul>
|
||||
<li>Server restart will disconnect all clients</li>
|
||||
<li>Database operations are irreversible</li>
|
||||
<li>Always backup before destructive operations</li>
|
||||
<li>Some commands require elevated permissions</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useWebSocket } from '../../composables/useWebSocket'
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
serverInfo,
|
||||
systemHealth,
|
||||
executeAdminCommand: executeAdminCommandFn,
|
||||
getServerInfo,
|
||||
getSystemHealth
|
||||
} = useWebSocket()
|
||||
|
||||
const adminCommand = ref('restart_server')
|
||||
const commandParams = ref('')
|
||||
const customCommand = ref('')
|
||||
const confirmationCode = ref('')
|
||||
const commandHistory = ref<Array<{
|
||||
command: string
|
||||
params: string
|
||||
result: any
|
||||
error: string
|
||||
success: boolean
|
||||
timestamp: number
|
||||
}>>([])
|
||||
|
||||
const executeAdminCommand = async () => {
|
||||
if (!isConnected.value || !confirmationCode.value.trim()) return
|
||||
|
||||
let command = adminCommand.value
|
||||
let params = {}
|
||||
|
||||
try {
|
||||
if (adminCommand.value === 'custom') {
|
||||
command = customCommand.value.trim()
|
||||
if (!command) {
|
||||
alert('Please enter a custom command')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (commandParams.value.trim()) {
|
||||
params = JSON.parse(commandParams.value)
|
||||
}
|
||||
|
||||
const result = await executeAdminCommandFn(command, {
|
||||
...params,
|
||||
confirmationCode: confirmationCode.value.trim(),
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
// Add to history
|
||||
commandHistory.value.unshift({
|
||||
command,
|
||||
params: JSON.stringify(params),
|
||||
result: result,
|
||||
error: '',
|
||||
success: true,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
// Clear form
|
||||
confirmationCode.value = ''
|
||||
commandParams.value = ''
|
||||
|
||||
} catch (error) {
|
||||
// Add error to history
|
||||
commandHistory.value.unshift({
|
||||
command,
|
||||
params: commandParams.value,
|
||||
result: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
success: false,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const clearCommandHistory = () => {
|
||||
commandHistory.value = []
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
return new Date(timestamp).toLocaleString()
|
||||
}
|
||||
|
||||
const formatUptime = (uptime: number) => {
|
||||
const seconds = Math.floor(uptime / 1000)
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const getCpuUsageClass = (usage: number) => {
|
||||
if (usage < 50) return 'usage-low'
|
||||
if (usage < 80) return 'usage-medium'
|
||||
return 'usage-high'
|
||||
}
|
||||
|
||||
const getMemoryUsageClass = (usage: number) => {
|
||||
if (usage < 60) return 'usage-low'
|
||||
if (usage < 85) return 'usage-medium'
|
||||
return 'usage-high'
|
||||
}
|
||||
|
||||
const getDiskUsageClass = (usage: number) => {
|
||||
if (usage < 70) return 'usage-low'
|
||||
if (usage < 90) return 'usage-medium'
|
||||
return 'usage-high'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-tab {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
padding: 24px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-group input,
|
||||
.input-group select,
|
||||
.input-group textarea {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.input-group input:focus,
|
||||
.input-group select:focus,
|
||||
.input-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #1976d2;
|
||||
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.1);
|
||||
}
|
||||
|
||||
.input-group input:disabled {
|
||||
background: #e9ecef;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #c82333;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.3);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #FFC107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #e0a800;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.server-info-section,
|
||||
.system-health-section {
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.server-info-section h3,
|
||||
.system-health-section h3 {
|
||||
margin-bottom: 16px;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.info-grid,
|
||||
.health-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-item,
|
||||
.health-item {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info-label,
|
||||
.health-label {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-value,
|
||||
.health-value {
|
||||
color: #333;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.health-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #e9ecef;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.health-bar-fill {
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.usage-low {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.usage-medium {
|
||||
background: #ffc107;
|
||||
}
|
||||
|
||||
.usage-high {
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
.command-history-section {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.command-history-section h3 {
|
||||
margin-bottom: 16px;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.command-history {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.command-item {
|
||||
margin: 16px 0;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.command-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.command-type {
|
||||
font-weight: 600;
|
||||
color: #1976d2;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.command-time {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.command-status {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.command-status.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.command-status.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.command-details {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.command-params,
|
||||
.command-result,
|
||||
.command-error {
|
||||
margin: 12px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.command-params strong,
|
||||
.command-result strong,
|
||||
.command-error strong {
|
||||
color: #333;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.command-result pre {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.command-error {
|
||||
color: #dc3545;
|
||||
background: #f8d7da;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.no-commands {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.security-notice {
|
||||
margin-top: 32px;
|
||||
padding: 20px;
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #ffc107;
|
||||
}
|
||||
|
||||
.security-notice h4 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #856404;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.security-notice p {
|
||||
margin: 0 0 12px 0;
|
||||
color: #856404;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.security-notice ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.security-notice li {
|
||||
margin: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.controls {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.info-grid,
|
||||
.health-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
397
examples/clientsocket/components/tabs/ConnectionTab.vue
Normal file
397
examples/clientsocket/components/tabs/ConnectionTab.vue
Normal file
@@ -0,0 +1,397 @@
|
||||
<template>
|
||||
<div class="connection-tab">
|
||||
<div class="controls">
|
||||
<div class="input-group">
|
||||
<label for="wsUrl">WebSocket URL</label>
|
||||
<input
|
||||
id="wsUrl"
|
||||
v-model="config.wsUrl"
|
||||
type="text"
|
||||
placeholder="ws://localhost:8080/api/v1/ws"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="userId">User ID</label>
|
||||
<input
|
||||
id="userId"
|
||||
v-model="config.userId"
|
||||
type="text"
|
||||
placeholder="anonymous"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="room">Room</label>
|
||||
<input
|
||||
id="room"
|
||||
v-model="config.room"
|
||||
type="text"
|
||||
placeholder="default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="staticId">Static ID (Optional)</label>
|
||||
<input
|
||||
id="staticId"
|
||||
v-model="config.staticId"
|
||||
type="text"
|
||||
placeholder="Leave empty for dynamic ID"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="input-group">
|
||||
<label class="checkbox-group">
|
||||
<input v-model="config.useIPBasedId" type="checkbox" />
|
||||
Use IP-based ID
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label class="checkbox-group">
|
||||
<input v-model="config.autoReconnect" type="checkbox" />
|
||||
Auto Reconnect
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label class="checkbox-group">
|
||||
<input v-model="config.heartbeatEnabled" type="checkbox" />
|
||||
Enable Heartbeat
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:class="{ 'btn-disabled': isConnecting }"
|
||||
:disabled="isConnecting"
|
||||
@click="connect"
|
||||
>
|
||||
<span v-if="isConnecting" class="loading-indicator"></span>
|
||||
{{ isConnected ? "Reconnect" : "Connect" }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
:disabled="!isConnected"
|
||||
@click="disconnect"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-info"
|
||||
:disabled="!isConnected"
|
||||
@click="testConnection"
|
||||
>
|
||||
Test Connection
|
||||
</button>
|
||||
|
||||
<button class="btn btn-secondary" @click="clearMessages">
|
||||
Clear Messages
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="input-group">
|
||||
<label for="maxReconnectAttempts">Max Reconnect Attempts</label>
|
||||
<input
|
||||
id="maxReconnectAttempts"
|
||||
v-model.number="config.maxReconnectAttempts"
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="reconnectDelay">Reconnect Delay (ms)</label>
|
||||
<input
|
||||
id="reconnectDelay"
|
||||
v-model.number="config.reconnectDelay"
|
||||
type="number"
|
||||
min="100"
|
||||
max="10000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="heartbeatInterval">Heartbeat Interval (ms)</label>
|
||||
<input
|
||||
id="heartbeatInterval"
|
||||
v-model.number="config.heartbeatInterval"
|
||||
type="number"
|
||||
min="1000"
|
||||
max="60000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="maxMessages">Max Messages</label>
|
||||
<input
|
||||
id="maxMessages"
|
||||
v-model.number="config.maxMessages"
|
||||
type="number"
|
||||
min="100"
|
||||
max="5000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connection-info" v-if="isConnected">
|
||||
<h3>Connection Information</h3>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<strong>Client ID:</strong>
|
||||
<span>{{ connectionState.clientId }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>Static ID:</strong>
|
||||
<span>{{ connectionState.staticId || "N/A" }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>IP Address:</strong>
|
||||
<span>{{ connectionState.ipAddress || "N/A" }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>User ID:</strong>
|
||||
<span>{{ connectionState.userId }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>Room:</strong>
|
||||
<span>{{ connectionState.currentRoom }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>Connected At:</strong>
|
||||
<span>{{
|
||||
new Date(connectionState.connectionStartTime || 0).toLocaleString()
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>Latency:</strong>
|
||||
<span>{{ connectionState.connectionLatency }}ms</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>Reconnect Attempts:</strong>
|
||||
<span>{{ connectionState.reconnectAttempts }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useWebSocket } from "../../composables/useWebSocket";
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
connectionState,
|
||||
config,
|
||||
connect,
|
||||
disconnect,
|
||||
testConnection,
|
||||
clearMessages,
|
||||
} = useWebSocket();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.connection-tab {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
padding: 24px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.input-group input:focus {
|
||||
outline: none;
|
||||
border-color: #1976d2;
|
||||
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.1);
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
margin: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(.btn-disabled) {
|
||||
background: #1565c0;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #c82333;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
background: #17a2b8;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-info:hover:not(:disabled) {
|
||||
background: #138496;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(23, 162, 184, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.3);
|
||||
}
|
||||
|
||||
.btn-disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #f3f3f3;
|
||||
border-top: 2px solid #1976d2;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.connection-info {
|
||||
margin-top: 32px;
|
||||
padding: 24px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.connection-info h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.info-item strong {
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
color: #666;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.controls {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
536
examples/clientsocket/components/tabs/DatabaseTab.vue
Normal file
536
examples/clientsocket/components/tabs/DatabaseTab.vue
Normal file
@@ -0,0 +1,536 @@
|
||||
<template>
|
||||
<div class="database-tab">
|
||||
<div class="controls">
|
||||
<div class="input-group">
|
||||
<label for="queryType">Query Type</label>
|
||||
<select id="queryType" v-model="queryType">
|
||||
<option value="select">SELECT</option>
|
||||
<option value="insert">INSERT</option>
|
||||
<option value="update">UPDATE</option>
|
||||
<option value="delete">DELETE</option>
|
||||
<option value="create_table">CREATE TABLE</option>
|
||||
<option value="drop_table">DROP TABLE</option>
|
||||
<option value="show_tables">SHOW TABLES</option>
|
||||
<option value="describe_table">DESCRIBE TABLE</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="tableName">Table Name</label>
|
||||
<input
|
||||
id="tableName"
|
||||
v-model="tableName"
|
||||
type="text"
|
||||
placeholder="Enter table name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="queryParams">Query Parameters (JSON)</label>
|
||||
<textarea
|
||||
id="queryParams"
|
||||
v-model="queryParams"
|
||||
placeholder='{"column": "value", "column2": "value2"}'
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="customQuery">Custom SQL Query</label>
|
||||
<textarea
|
||||
id="customQuery"
|
||||
v-model="customQuery"
|
||||
placeholder="Enter custom SQL query"
|
||||
rows="4"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="!isConnected || (!tableName.trim() && !customQuery.trim())"
|
||||
@click="executeQuery"
|
||||
>
|
||||
Execute Query
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
:disabled="!isConnected"
|
||||
@click="getStats"
|
||||
>
|
||||
Get Database Stats
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
:disabled="!isConnected"
|
||||
@click="getMonitoringData"
|
||||
>
|
||||
Get Monitoring Data
|
||||
</button>
|
||||
|
||||
<button class="btn btn-secondary" @click="clearQueryHistory">
|
||||
Clear Query History
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Query History -->
|
||||
<div class="query-history-section">
|
||||
<h3>Query History</h3>
|
||||
<div class="query-history">
|
||||
<div
|
||||
v-for="(query, index) in queryHistory.slice(0, 10)"
|
||||
:key="index"
|
||||
class="query-item"
|
||||
>
|
||||
<div class="query-header">
|
||||
<div class="query-type">{{ query.type }}</div>
|
||||
<div class="query-time">{{ formatTime(query.timestamp) }}</div>
|
||||
<div
|
||||
class="query-status"
|
||||
:class="query.success ? 'success' : 'error'"
|
||||
>
|
||||
{{ query.success ? "Success" : "Error" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="query-details">
|
||||
<div class="query-sql">{{ query.sql }}</div>
|
||||
<div v-if="query.params" class="query-params">
|
||||
<strong>Parameters:</strong> {{ query.params }}
|
||||
</div>
|
||||
<div v-if="query.result" class="query-result">
|
||||
<strong>Result:</strong>
|
||||
<pre>{{ JSON.stringify(query.result, null, 2) }}</pre>
|
||||
</div>
|
||||
<div v-if="query.error" class="query-error">
|
||||
<strong>Error:</strong> {{ query.error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="queryHistory.length === 0" class="no-queries">
|
||||
No queries executed yet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database Stats -->
|
||||
<div class="database-stats-section" v-if="stats">
|
||||
<h3>Database Statistics</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Connected Clients</div>
|
||||
<div class="stat-value">{{ stats.connected_clients }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Unique IPs</div>
|
||||
<div class="stat-value">{{ stats.unique_ips }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Static Clients</div>
|
||||
<div class="stat-value">{{ stats.static_clients }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Active Rooms</div>
|
||||
<div class="stat-value">{{ stats.active_rooms }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Message Queue Size</div>
|
||||
<div class="stat-value">{{ stats.message_queue_size }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Queue Workers</div>
|
||||
<div class="stat-value">{{ stats.queue_workers }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Uptime</div>
|
||||
<div class="stat-value">{{ formatUptime(stats.uptime) }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Last Updated</div>
|
||||
<div class="stat-value">{{ formatTime(stats.timestamp) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useWebSocket } from "../../composables/useWebSocket";
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
stats,
|
||||
executeDatabaseQuery,
|
||||
getStats,
|
||||
getMonitoringData,
|
||||
} = useWebSocket();
|
||||
|
||||
const queryType = ref("select");
|
||||
const tableName = ref("");
|
||||
const queryParams = ref("");
|
||||
const customQuery = ref("");
|
||||
const queryHistory = ref<
|
||||
Array<{
|
||||
type: string;
|
||||
sql: string;
|
||||
params: string;
|
||||
result: any;
|
||||
error: string;
|
||||
success: boolean;
|
||||
timestamp: number;
|
||||
}>
|
||||
>([]);
|
||||
|
||||
const executeQuery = async () => {
|
||||
if (!isConnected.value) return;
|
||||
|
||||
let sql = "";
|
||||
let params = {};
|
||||
|
||||
try {
|
||||
if (customQuery.value.trim()) {
|
||||
sql = customQuery.value.trim();
|
||||
params = queryParams.value ? JSON.parse(queryParams.value) : {};
|
||||
} else {
|
||||
switch (queryType.value) {
|
||||
case "select":
|
||||
sql = `SELECT * FROM ${tableName.value}`;
|
||||
break;
|
||||
case "insert":
|
||||
params = JSON.parse(queryParams.value || "{}");
|
||||
sql = `INSERT INTO ${tableName.value} SET ?`;
|
||||
break;
|
||||
case "update":
|
||||
params = JSON.parse(queryParams.value || "{}");
|
||||
sql = `UPDATE ${tableName.value} SET ? WHERE id = ?`;
|
||||
break;
|
||||
case "delete":
|
||||
sql = `DELETE FROM ${tableName.value} WHERE id = ?`;
|
||||
break;
|
||||
case "create_table":
|
||||
sql = `CREATE TABLE IF NOT EXISTS ${tableName.value} (id INT AUTO_INCREMENT PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`;
|
||||
break;
|
||||
case "drop_table":
|
||||
sql = `DROP TABLE IF EXISTS ${tableName.value}`;
|
||||
break;
|
||||
case "show_tables":
|
||||
sql = "SHOW TABLES";
|
||||
break;
|
||||
case "describe_table":
|
||||
sql = `DESCRIBE ${tableName.value}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeDatabaseQuery(queryData: {
|
||||
sql: string;
|
||||
params?: any;
|
||||
table?: string;
|
||||
}): Promise<any> {
|
||||
// Function implementation
|
||||
}
|
||||
// Add to history
|
||||
queryHistory.value.unshift({
|
||||
type: queryType.value,
|
||||
sql,
|
||||
params: JSON.stringify(params),
|
||||
result: await executeDatabaseQuery({
|
||||
sql,
|
||||
params,
|
||||
table: tableName.value,
|
||||
}),
|
||||
error: "",
|
||||
success: true,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
// Add error to history
|
||||
queryHistory.value.unshift({
|
||||
type: queryType.value,
|
||||
sql: customQuery.value || sql,
|
||||
params: queryParams.value,
|
||||
result: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
success: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const clearQueryHistory = () => {
|
||||
queryHistory.value = [];
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
};
|
||||
|
||||
const formatUptime = (uptime: number) => {
|
||||
const seconds = Math.floor(uptime / 1000);
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.database-tab {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
padding: 24px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-group input,
|
||||
.input-group select,
|
||||
.input-group textarea {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.input-group input:focus,
|
||||
.input-group select:focus,
|
||||
.input-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #1976d2;
|
||||
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.1);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #1565c0;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.3);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.query-history-section {
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.query-history-section h3 {
|
||||
margin-bottom: 16px;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.query-history {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.query-item {
|
||||
margin: 16px 0;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.query-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.query-type {
|
||||
font-weight: 600;
|
||||
color: #1976d2;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.query-time {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.query-status {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.query-status.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.query-status.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.query-details {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.query-sql {
|
||||
font-family: monospace;
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.query-params,
|
||||
.query-result,
|
||||
.query-error {
|
||||
margin: 12px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.query-params strong,
|
||||
.query-result strong,
|
||||
.query-error strong {
|
||||
color: #333;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.query-result pre {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.query-error {
|
||||
color: #dc3545;
|
||||
background: #f8d7da;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.no-queries {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.database-stats-section {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.database-stats-section h3 {
|
||||
margin-bottom: 16px;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #333;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.controls {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.query-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
602
examples/clientsocket/components/tabs/MessagingTab.vue
Normal file
602
examples/clientsocket/components/tabs/MessagingTab.vue
Normal file
@@ -0,0 +1,602 @@
|
||||
<template>
|
||||
<div class="messaging-tab">
|
||||
<!-- Connection Status -->
|
||||
<div class="connection-status" :class="connectionState.connectionStatus">
|
||||
<div class="status-indicator">
|
||||
<div class="status-dot" :class="connectionState.connectionStatus"></div>
|
||||
<span class="status-text">
|
||||
{{
|
||||
connectionState.connectionStatus === "connected"
|
||||
? "Connected"
|
||||
: connectionState.connectionStatus === "connecting"
|
||||
? "Connecting..."
|
||||
: connectionState.connectionStatus === "error"
|
||||
? "Connection Error"
|
||||
: "Disconnected"
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="connection-details" v-if="connectionState.clientId">
|
||||
<span>Client ID: {{ connectionState.clientId }}</span>
|
||||
<span v-if="connectionState.currentRoom"
|
||||
>Room: {{ connectionState.currentRoom }}</span
|
||||
>
|
||||
<span v-if="connectionState.connectionLatency > 0"
|
||||
>Latency: {{ connectionState.connectionLatency }}ms</span
|
||||
>
|
||||
<span v-if="connectionState.connectionHealth"
|
||||
>Health: {{ connectionState.connectionHealth }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="input-group">
|
||||
<label for="broadcastMessage">Broadcast Message</label>
|
||||
<textarea
|
||||
id="broadcastMessage"
|
||||
v-model="messageText"
|
||||
placeholder="Enter message to broadcast to all clients"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="targetClientId"
|
||||
>Target Client ID (for direct messages)</label
|
||||
>
|
||||
<input
|
||||
id="targetClientId"
|
||||
v-model="targetClientId"
|
||||
type="text"
|
||||
placeholder="Client ID for direct message"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="roomMessage">Room Message</label>
|
||||
<textarea
|
||||
id="roomMessage"
|
||||
v-model="roomMessage"
|
||||
placeholder="Enter message to send to specific room"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="targetRoom">Target Room</label>
|
||||
<input
|
||||
id="targetRoom"
|
||||
v-model="targetRoom"
|
||||
type="text"
|
||||
placeholder="Room name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="!isConnected || !messageText.trim()"
|
||||
@click="handleSendBroadcast"
|
||||
>
|
||||
Send Broadcast
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-info"
|
||||
:disabled="
|
||||
!isConnected || !targetClientId.trim() || !messageText.trim()
|
||||
"
|
||||
@click="handleSendDirectMessage"
|
||||
>
|
||||
Send Direct Message
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-success"
|
||||
:disabled="!isConnected || !targetRoom.trim() || !roomMessage.trim()"
|
||||
@click="handleSendRoomMessage"
|
||||
>
|
||||
Send Room Message
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-warning"
|
||||
:disabled="!isConnected"
|
||||
@click="sendHeartbeat"
|
||||
>
|
||||
Send Heartbeat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
:disabled="!isConnected"
|
||||
@click="getOnlineUsers"
|
||||
>
|
||||
Get Online Users
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
:disabled="!isConnected"
|
||||
@click="getServerInfo"
|
||||
>
|
||||
Get Server Info
|
||||
</button>
|
||||
|
||||
<button class="btn btn-secondary" @click="clearMessages">
|
||||
Clear Messages
|
||||
</button>
|
||||
|
||||
<button class="btn btn-secondary" @click="clearActivityLog">
|
||||
Clear Activity Log
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Online Users -->
|
||||
<div class="online-users-section" v-if="onlineUsers.length > 0">
|
||||
<h3>Online Users ({{ onlineUsers.length }})</h3>
|
||||
<div class="online-users">
|
||||
<div
|
||||
v-for="user in onlineUsers"
|
||||
:key="user.client_id"
|
||||
class="user-item"
|
||||
:class="{
|
||||
'current-user': user.client_id === connectionState.clientId,
|
||||
}"
|
||||
>
|
||||
<div class="user-info">
|
||||
<div class="user-id">{{ user.client_id }}</div>
|
||||
<div class="user-details">
|
||||
<span>Room: {{ user.room }}</span>
|
||||
<span>IP: {{ user.ip_address }}</span>
|
||||
<span>Connected: {{ formatTime(user.connected_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<button
|
||||
class="btn btn-sm btn-info"
|
||||
@click="setTargetClient(user.client_id)"
|
||||
>
|
||||
Direct Message
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity Log -->
|
||||
<div class="activity-log-section">
|
||||
<h3>Activity Log</h3>
|
||||
<div class="activity-log">
|
||||
<div
|
||||
v-for="(activity, index) in activityLog.slice(0, 20)"
|
||||
:key="index"
|
||||
class="activity-item"
|
||||
>
|
||||
<div class="activity-time">{{ formatTime(activity.timestamp) }}</div>
|
||||
<div class="activity-event">{{ activity.event }}</div>
|
||||
<div class="activity-details">{{ activity.details }}</div>
|
||||
</div>
|
||||
<div v-if="activityLog.length === 0" class="no-activity">
|
||||
No activity logged yet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useWebSocket } from "../../composables/useWebSocket";
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
connectionState,
|
||||
onlineUsers,
|
||||
activityLog,
|
||||
broadcastMessage,
|
||||
sendDirectMessage,
|
||||
sendRoomMessage,
|
||||
sendHeartbeat,
|
||||
getOnlineUsers,
|
||||
getServerInfo,
|
||||
clearMessages,
|
||||
clearActivityLog,
|
||||
connect,
|
||||
} = useWebSocket();
|
||||
|
||||
const messageText = ref("");
|
||||
const targetClientId = ref("");
|
||||
const roomMessage = ref("");
|
||||
const targetRoom = ref("");
|
||||
|
||||
const handleSendBroadcast = () => {
|
||||
if (messageText.value.trim()) {
|
||||
broadcastMessage(messageText.value.trim());
|
||||
messageText.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendDirectMessage = () => {
|
||||
if (targetClientId.value.trim() && messageText.value.trim()) {
|
||||
sendDirectMessage(targetClientId.value.trim(), messageText.value.trim());
|
||||
messageText.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendRoomMessage = () => {
|
||||
if (targetRoom.value.trim() && roomMessage.value.trim()) {
|
||||
sendRoomMessage(targetRoom.value.trim(), roomMessage.value.trim());
|
||||
roomMessage.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const setTargetClient = (clientId: string) => {
|
||||
targetClientId.value = clientId;
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
};
|
||||
|
||||
// Initialize connection on component mount
|
||||
onMounted(() => {
|
||||
console.log("MessagingTab mounted, initializing WebSocket connection...");
|
||||
if (!isConnected.value) {
|
||||
console.log("Attempting to connect to WebSocket...");
|
||||
connect();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.messaging-tab {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* Connection Status Styles */
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 24px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.connection-status.connected {
|
||||
background: #e8f5e8;
|
||||
border-color: #4caf50;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.connection-status.connecting {
|
||||
background: #fff3e0;
|
||||
border-color: #ff9800;
|
||||
color: #ef6c00;
|
||||
}
|
||||
|
||||
.connection-status.error {
|
||||
background: #ffebee;
|
||||
border-color: #f44336;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.connection-status.disconnected {
|
||||
background: #f5f5f5;
|
||||
border-color: #9e9e9e;
|
||||
color: #616161;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.status-dot.connecting {
|
||||
background: #ff9800;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: #f44336;
|
||||
animation: pulse 0.5s infinite;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: #9e9e9e;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.connection-details {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.connection-details span {
|
||||
padding: 4px 8px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
padding: 24px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-group input,
|
||||
.input-group textarea {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.input-group input:focus,
|
||||
.input-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #1976d2;
|
||||
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.1);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #1565c0;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3);
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
background: #17a2b8;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-info:hover:not(:disabled) {
|
||||
background: #138496;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(23, 162, 184, 0.3);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background: #218838;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
background: #e0a800;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.3);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.online-users-section {
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.online-users-section h3 {
|
||||
margin-bottom: 16px;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.online-users {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.user-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
margin: 8px 0;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.user-item.current-user {
|
||||
border-left: 4px solid #1976d2;
|
||||
background: #f0f8ff;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.user-id {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.activity-log-section {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.activity-log-section h3 {
|
||||
margin-bottom: 16px;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.activity-log {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: grid;
|
||||
grid-template-columns: 150px 120px 1fr;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
margin: 8px 0;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e9ecef;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
color: #666;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.activity-event {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.activity-details {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.no-activity {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.controls {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
637
examples/clientsocket/components/tabs/MonitoringTab.vue
Normal file
637
examples/clientsocket/components/tabs/MonitoringTab.vue
Normal file
@@ -0,0 +1,637 @@
|
||||
<template>
|
||||
<div class="monitoring-tab">
|
||||
<div class="controls">
|
||||
<div class="input-group">
|
||||
<label for="notificationChannel">Notification Channel</label>
|
||||
<input
|
||||
id="notificationChannel"
|
||||
v-model="notificationChannel"
|
||||
type="text"
|
||||
placeholder="Enter notification channel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="notificationPayload">Notification Payload (JSON)</label>
|
||||
<textarea
|
||||
id="notificationPayload"
|
||||
v-model="notificationPayload"
|
||||
placeholder='{"message": "Hello", "type": "info"}'
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="triggerEvent">Trigger Event</label>
|
||||
<select id="triggerEvent" v-model="triggerEvent">
|
||||
<option value="user_joined">User Joined</option>
|
||||
<option value="user_left">User Left</option>
|
||||
<option value="message_sent">Message Sent</option>
|
||||
<option value="connection_lost">Connection Lost</option>
|
||||
<option value="server_restart">Server Restart</option>
|
||||
<option value="custom">Custom Event</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="customEvent">Custom Event Name</label>
|
||||
<input
|
||||
id="customEvent"
|
||||
v-model="customEvent"
|
||||
type="text"
|
||||
placeholder="custom_event_name"
|
||||
:disabled="triggerEvent !== 'custom'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="
|
||||
!isConnected ||
|
||||
!notificationChannel.trim() ||
|
||||
!notificationPayload.trim()
|
||||
"
|
||||
@click="triggerNotification"
|
||||
>
|
||||
Trigger Notification
|
||||
</button>
|
||||
|
||||
<button class="btn btn-info" :disabled="!isConnected" @click="getStats">
|
||||
Refresh Stats
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
:disabled="!isConnected"
|
||||
@click="getMonitoringData"
|
||||
>
|
||||
Get Monitoring Data
|
||||
</button>
|
||||
|
||||
<button class="btn btn-warning" @click="clearMonitoringData">
|
||||
Clear Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Connection Health -->
|
||||
<div class="health-section">
|
||||
<h3>Connection Health</h3>
|
||||
<div class="health-grid">
|
||||
<div class="health-item">
|
||||
<div class="health-label">Status</div>
|
||||
<div class="health-value" :class="connectionHealthClass">
|
||||
{{ connectionHealthText }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<div class="health-label">Latency</div>
|
||||
<div class="health-value">
|
||||
{{ connectionState.connectionLatency }}ms
|
||||
</div>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<div class="health-label">Messages Sent</div>
|
||||
<div class="health-value">{{ connectionState.messagesSent }}</div>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<div class="health-label">Messages Received</div>
|
||||
<div class="health-value">{{ connectionState.messagesReceived }}</div>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<div class="health-label">Reconnect Attempts</div>
|
||||
<div class="health-value">
|
||||
{{ connectionState.reconnectAttempts }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<div class="health-label">Connection Time</div>
|
||||
<div class="health-value">{{ connectionState.uptime }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server Statistics -->
|
||||
<div class="stats-section" v-if="stats">
|
||||
<h3>Server Statistics</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Connected Clients</div>
|
||||
<div class="stat-value">{{ stats.connected_clients }}</div>
|
||||
<div
|
||||
class="stat-change"
|
||||
:class="getStatChangeClass('connected_clients')"
|
||||
>
|
||||
{{ getStatChange("connected_clients") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Unique IPs</div>
|
||||
<div class="stat-value">{{ stats.unique_ips }}</div>
|
||||
<div class="stat-change" :class="getStatChangeClass('unique_ips')">
|
||||
{{ getStatChange("unique_ips") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Static Clients</div>
|
||||
<div class="stat-value">{{ stats.static_clients }}</div>
|
||||
<div
|
||||
class="stat-change"
|
||||
:class="getStatChangeClass('static_clients')"
|
||||
>
|
||||
{{ getStatChange("static_clients") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Active Rooms</div>
|
||||
<div class="stat-value">{{ stats.active_rooms }}</div>
|
||||
<div class="stat-change" :class="getStatChangeClass('active_rooms')">
|
||||
{{ getStatChange("active_rooms") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Message Queue</div>
|
||||
<div class="stat-value">{{ stats.message_queue_size }}</div>
|
||||
<div
|
||||
class="stat-change"
|
||||
:class="getStatChangeClass('message_queue_size')"
|
||||
>
|
||||
{{ getStatChange("message_queue_size") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Queue Workers</div>
|
||||
<div class="stat-value">{{ stats.queue_workers }}</div>
|
||||
<div class="stat-change" :class="getStatChangeClass('queue_workers')">
|
||||
{{ getStatChange("queue_workers") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Server Uptime</div>
|
||||
<div class="stat-value">{{ formatUptime(stats.uptime) }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Last Updated</div>
|
||||
<div class="stat-value">{{ formatTime(stats.timestamp) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monitoring Data -->
|
||||
<div class="monitoring-data-section" v-if="monitoringData">
|
||||
<h3>Monitoring Data</h3>
|
||||
<div class="monitoring-content">
|
||||
<pre>{{ JSON.stringify(monitoringData, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification History -->
|
||||
<div class="notification-history-section">
|
||||
<h3>Notification History</h3>
|
||||
<div class="notification-history">
|
||||
<div
|
||||
v-for="(notification, index) in notificationHistory.slice(0, 10)"
|
||||
:key="index"
|
||||
class="notification-item"
|
||||
>
|
||||
<div class="notification-header">
|
||||
<div class="notification-channel">{{ notification.channel }}</div>
|
||||
<div class="notification-time">
|
||||
{{ formatTime(notification.timestamp) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="notification-payload">
|
||||
<pre>{{ JSON.stringify(notification.payload, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="notificationHistory.length === 0" class="no-notifications">
|
||||
No notifications sent yet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { useWebSocket } from "../../composables/useWebSocket";
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
connectionState,
|
||||
stats,
|
||||
monitoringData,
|
||||
triggerNotification: triggerNotificationFn,
|
||||
getStats,
|
||||
getMonitoringData,
|
||||
} = useWebSocket();
|
||||
|
||||
const notificationChannel = ref("");
|
||||
const notificationPayload = ref("");
|
||||
const triggerEvent = ref("user_joined");
|
||||
const customEvent = ref("");
|
||||
const notificationHistory = ref<
|
||||
Array<{
|
||||
channel: string;
|
||||
payload: any;
|
||||
timestamp: number;
|
||||
}>
|
||||
>([]);
|
||||
|
||||
const connectionHealthClass = computed(() => {
|
||||
switch (connectionState.connectionHealth) {
|
||||
case "excellent":
|
||||
return "health-excellent";
|
||||
case "good":
|
||||
return "health-good";
|
||||
case "warning":
|
||||
return "health-warning";
|
||||
case "poor":
|
||||
return "health-poor";
|
||||
default:
|
||||
return "health-poor";
|
||||
}
|
||||
});
|
||||
|
||||
const connectionHealthText = computed(() => {
|
||||
switch (connectionState.connectionHealth) {
|
||||
case "excellent":
|
||||
return "Excellent";
|
||||
case "good":
|
||||
return "Good";
|
||||
case "warning":
|
||||
return "Warning";
|
||||
case "poor":
|
||||
return "Poor";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
});
|
||||
|
||||
const triggerNotification = () => {
|
||||
if (!notificationChannel.value.trim() || !notificationPayload.value.trim())
|
||||
return;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(notificationPayload.value);
|
||||
const eventName =
|
||||
triggerEvent.value === "custom" ? customEvent.value : triggerEvent.value;
|
||||
|
||||
triggerNotificationFn(notificationChannel.value.trim());
|
||||
|
||||
// Add to history
|
||||
notificationHistory.value.unshift({
|
||||
channel: notificationChannel.value.trim(),
|
||||
payload,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Clear form
|
||||
notificationChannel.value = "";
|
||||
notificationPayload.value = "";
|
||||
} catch (error) {
|
||||
alert(
|
||||
"Invalid JSON payload: " +
|
||||
(error instanceof Error ? error.message : String(error))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const clearMonitoringData = () => {
|
||||
notificationHistory.value = [];
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
};
|
||||
|
||||
const formatUptime = (uptime: number) => {
|
||||
const seconds = Math.floor(uptime / 1000);
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
// Mock stat changes for demonstration
|
||||
const previousStats = ref<any>(null);
|
||||
|
||||
const getStatChange = (statName: string) => {
|
||||
if (!previousStats.value || !stats.value) return "";
|
||||
|
||||
const current = stats.value[statName as keyof typeof stats.value] as number;
|
||||
const previous =
|
||||
previousStats.value[statName as keyof typeof previousStats.value];
|
||||
|
||||
if (current > previous) return `+${current - previous}`;
|
||||
if (current < previous) return `-${previous - current}`;
|
||||
return "0";
|
||||
};
|
||||
|
||||
const getStatChangeClass = (statName: string) => {
|
||||
if (!previousStats.value || !stats.value) return "";
|
||||
|
||||
const current = stats.value[statName as keyof typeof stats.value];
|
||||
const previous =
|
||||
previousStats.value[statName as keyof typeof previousStats.value];
|
||||
|
||||
if (current > previous) return "stat-increase";
|
||||
if (current < previous) return "stat-decrease";
|
||||
return "stat-unchanged";
|
||||
};
|
||||
|
||||
// Update previous stats when new stats arrive
|
||||
const updatePreviousStats = () => {
|
||||
if (stats.value) {
|
||||
previousStats.value = { ...stats.value };
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for stats changes
|
||||
import { watch } from "vue";
|
||||
watch(stats, updatePreviousStats, { deep: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.monitoring-tab {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
padding: 24px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-group input,
|
||||
.input-group select,
|
||||
.input-group textarea {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.input-group input:focus,
|
||||
.input-group select:focus,
|
||||
.input-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #1976d2;
|
||||
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.1);
|
||||
}
|
||||
|
||||
.input-group input:disabled {
|
||||
background: #e9ecef;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #1565c0;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3);
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
background: #17a2b8;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-info:hover:not(:disabled) {
|
||||
background: #138496;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(23, 162, 184, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.3);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #e0a800;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.health-section,
|
||||
.stats-section {
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.health-section h3,
|
||||
.stats-section h3 {
|
||||
margin-bottom: 16px;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.health-grid,
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.health-item,
|
||||
.stat-item {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.health-label,
|
||||
.stat-label {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.health-value {
|
||||
color: #333;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #333;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-increase {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.stat-decrease {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.stat-unchanged {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.monitoring-data-section {
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.monitoring-data-section h3 {
|
||||
margin-bottom: 16px;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.monitoring-content {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.monitoring-content pre {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.notification-history-section {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.notification-history-section h3 {
|
||||
margin-bottom: 16px;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.notification-history {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
margin: 16px 0;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.notification-channel {
|
||||
font-weight: 600;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.notification-payload {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.notification-payload pre {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.no-notifications {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.controls {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.health-grid,
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1073
examples/clientsocket/composables/note
Normal file
1073
examples/clientsocket/composables/note
Normal file
File diff suppressed because it is too large
Load Diff
1073
examples/clientsocket/composables/useWebSocket.fixed
Normal file
1073
examples/clientsocket/composables/useWebSocket.fixed
Normal file
File diff suppressed because it is too large
Load Diff
1073
examples/clientsocket/composables/useWebSocket.ts
Normal file
1073
examples/clientsocket/composables/useWebSocket.ts
Normal file
File diff suppressed because it is too large
Load Diff
318
examples/clientsocket/composables/useWebSocket.ts.backup
Normal file
318
examples/clientsocket/composables/useWebSocket.ts.backup
Normal file
@@ -0,0 +1,318 @@
|
||||
import { ref, computed, reactive, nextTick } from 'vue'
|
||||
import type {
|
||||
WebSocketMessage,
|
||||
ConnectionState,
|
||||
WebSocketConfig,
|
||||
MessageHistory,
|
||||
ConnectionStats,
|
||||
MonitoringData,
|
||||
ClientInfo,
|
||||
OnlineUser,
|
||||
ActivityLog
|
||||
} from '../types/websocket'
|
||||
|
||||
export const useWebSocket = () => {
|
||||
// Check if we're in browser environment
|
||||
const isBrowser = process.client
|
||||
|
||||
const ws = ref<WebSocket | null>(null)
|
||||
const isConnected = ref(false)
|
||||
const isConnecting = ref(false)
|
||||
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>('disconnected')
|
||||
|
||||
const connectionState = reactive<ConnectionState>({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
connectionStatus: 'disconnected',
|
||||
clientId: null,
|
||||
staticId: null,
|
||||
currentRoom: null,
|
||||
userId: 'anonymous',
|
||||
ipAddress: null,
|
||||
connectionStartTime: null,
|
||||
lastPingTime: null,
|
||||
connectionLatency: 0,
|
||||
connectionHealth: 'poor',
|
||||
reconnectAttempts: 0,
|
||||
messagesReceived: 0,
|
||||
messagesSent: 0,
|
||||
uptime: '00:00:00'
|
||||
})
|
||||
|
||||
const config = reactive<WebSocketConfig>({
|
||||
wsUrl: 'ws://localhost:8080/api/v1/ws',
|
||||
userId: 'anonymous',
|
||||
room: 'default',
|
||||
staticId: '',
|
||||
useIPBasedId: false,
|
||||
autoReconnect: true,
|
||||
heartbeatEnabled: true,
|
||||
maxReconnectAttempts: 10,
|
||||
reconnectDelay: 1000,
|
||||
maxReconnectDelay: 30000,
|
||||
heartbeatInterval: 30000,
|
||||
heartbeatTimeout: 5000,
|
||||
maxMissedHeartbeats: 3,
|
||||
maxMessages: 1000,
|
||||
messageWarningThreshold: 800,
|
||||
actionThrottle: 100
|
||||
})
|
||||
|
||||
const messages = ref<MessageHistory[]>([])
|
||||
const stats = ref<ConnectionStats | null>(null)
|
||||
const monitoringData = ref<MonitoringData | null>(null)
|
||||
const onlineUsers = ref<OnlineUser[]>([])
|
||||
const activityLog = ref<ActivityLog[]>([])
|
||||
|
||||
let reconnectTimeout: number | null = null
|
||||
let heartbeatInterval: number | null = null
|
||||
let heartbeatTimeout: number | null = null
|
||||
let missedHeartbeats = 0
|
||||
let lastHeartbeatTime = 0
|
||||
let messageCount = 0
|
||||
|
||||
// Only run WebSocket logic in browser
|
||||
if (isBrowser) {
|
||||
// WebSocket connection logic here
|
||||
}
|
||||
|
||||
const addMessage = (type: string, data: any, messageId?: string) => {
|
||||
if (!isBrowser) return
|
||||
|
||||
const message: MessageHistory = {
|
||||
timestamp: new Date(),
|
||||
type,
|
||||
data,
|
||||
messageId,
|
||||
size: JSON.stringify(data).length
|
||||
}
|
||||
|
||||
messages.value.unshift(message)
|
||||
messageCount++
|
||||
|
||||
// Keep only the last maxMessages
|
||||
if (messages.value.length > config.maxMessages) {
|
||||
messages.value = messages.value.slice(0, config.maxMessages)
|
||||
}
|
||||
|
||||
// Update connection state
|
||||
connectionState.messagesReceived++
|
||||
}
|
||||
|
||||
const connectionHealthColor = computed(() => {
|
||||
switch (connectionState.connectionHealth) {
|
||||
case 'excellent': return '#4CAF50'
|
||||
case 'good': return '#2196F3'
|
||||
case 'warning': return '#FFC107'
|
||||
case 'poor': return '#F44336'
|
||||
default: return '#9E9E9E'
|
||||
}
|
||||
})
|
||||
|
||||
const connectionHealthText = computed(() => {
|
||||
switch (connectionState.connectionHealth) {
|
||||
case 'excellent': return 'Excellent'
|
||||
case 'good': return 'Good'
|
||||
case 'warning': return 'Warning'
|
||||
case 'poor': return 'Poor'
|
||||
default: return 'Unknown'
|
||||
}
|
||||
})
|
||||
|
||||
// Admin functionality
|
||||
const serverInfo = ref<any>(null)
|
||||
const systemHealth = ref<any>(null)
|
||||
|
||||
const executeAdminCommand = async (command: string, params: any) => {
|
||||
if (!isBrowser || !ws.value) throw new Error('Not connected')
|
||||
|
||||
const message = {
|
||||
type: 'admin_command',
|
||||
command,
|
||||
params,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
ws.value.send(JSON.stringify(message))
|
||||
return { success: true, message: 'Command sent successfully' }
|
||||
}
|
||||
|
||||
const getServerInfo = async () => {
|
||||
if (!isBrowser || !ws.value) throw new Error('Not connected')
|
||||
|
||||
const message = {
|
||||
type: 'get_server_info',
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
ws.value.send(JSON.stringify(message))
|
||||
}
|
||||
|
||||
const getSystemHealth = async () => {
|
||||
if (!isBrowser || !ws.value) throw new Error('Not connected')
|
||||
|
||||
const message = {
|
||||
type: 'get_system_health',
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
ws.value.send(JSON.stringify(message))
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
const cleanup = () => {
|
||||
if (!isBrowser) return
|
||||
|
||||
disconnect()
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout)
|
||||
}
|
||||
stopHeartbeat()
|
||||
}
|
||||
|
||||
// WebSocket connection methods (only available in browser)
|
||||
const connect = () => {
|
||||
if (!isBrowser) return
|
||||
// WebSocket connection logic
|
||||
}
|
||||
|
||||
const disconnect = () => {
|
||||
if (!isBrowser) return
|
||||
// WebSocket disconnection logic
|
||||
}
|
||||
|
||||
const sendMessage = (message: any) => {
|
||||
if (!isBrowser || !ws.value) return
|
||||
// Send message logic
|
||||
}
|
||||
|
||||
const broadcastMessage = (message: string) => {
|
||||
if (!isBrowser || !ws.value) return
|
||||
// Broadcast message logic
|
||||
}
|
||||
|
||||
const sendDirectMessage = (clientId: string, message: string) => {
|
||||
if (!isBrowser || !ws.value) return
|
||||
// Direct message logic
|
||||
}
|
||||
|
||||
const sendRoomMessage = (room: string, message: string) => {
|
||||
if (!isBrowser || !ws.value) return
|
||||
// Room message logic
|
||||
}
|
||||
|
||||
const getOnlineUsers = () => {
|
||||
if (!isBrowser || !ws.value) return
|
||||
// Get online users logic
|
||||
}
|
||||
|
||||
const testConnection = () => {
|
||||
if (!isBrowser || !ws.value) return
|
||||
// Test connection logic
|
||||
}
|
||||
|
||||
const sendHeartbeat = () => {
|
||||
if (!isBrowser || !ws.value) return
|
||||
// Send heartbeat logic
|
||||
}
|
||||
|
||||
const executeDatabaseQuery = async (query: string) => {
|
||||
if (!isBrowser || !ws.value) return
|
||||
// Database query logic
|
||||
}
|
||||
|
||||
const triggerNotification = async (message: string) => {
|
||||
if (!isBrowser || !ws.value) return
|
||||
// Notification logic
|
||||
}
|
||||
|
||||
const getStats = () => {
|
||||
if (!isBrowser || !ws.value) return
|
||||
// Get stats logic
|
||||
}
|
||||
|
||||
const getMonitoringData = () => {
|
||||
if (!isBrowser || !ws.value) return
|
||||
// Get monitoring data logic
|
||||
}
|
||||
|
||||
const clearMessages = () => {
|
||||
if (!isBrowser) return
|
||||
messages.value = []
|
||||
messageCount = 0
|
||||
}
|
||||
|
||||
const clearActivityLog = () => {
|
||||
if (!isBrowser) return
|
||||
activityLog.value = []
|
||||
}
|
||||
|
||||
const getMessagesByType = (type: string) => {
|
||||
return messages.value.filter(msg => msg.type === type)
|
||||
}
|
||||
|
||||
const getRecentMessages = (count: number = 10) => {
|
||||
return messages.value.slice(0, count)
|
||||
}
|
||||
|
||||
const stopHeartbeat = () => {
|
||||
if (!isBrowser) return
|
||||
// Stop heartbeat logic
|
||||
}
|
||||
|
||||
const isMessageLimitReached = computed(() => {
|
||||
return messages.value.length >= config.maxMessages
|
||||
})
|
||||
|
||||
const shouldShowMessageWarning = computed(() => {
|
||||
return messages.value.length >= config.messageWarningThreshold
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
ws,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
connectionStatus,
|
||||
connectionState,
|
||||
config,
|
||||
messages,
|
||||
stats,
|
||||
monitoringData,
|
||||
onlineUsers,
|
||||
activityLog,
|
||||
|
||||
// Admin state
|
||||
serverInfo,
|
||||
systemHealth,
|
||||
|
||||
// Methods
|
||||
connect,
|
||||
disconnect,
|
||||
sendMessage,
|
||||
broadcastMessage,
|
||||
sendDirectMessage,
|
||||
sendRoomMessage,
|
||||
getServerInfo,
|
||||
getOnlineUsers,
|
||||
testConnection,
|
||||
sendHeartbeat,
|
||||
executeDatabaseQuery,
|
||||
triggerNotification,
|
||||
getStats,
|
||||
getMonitoringData,
|
||||
executeAdminCommand,
|
||||
getSystemHealth,
|
||||
clearMessages,
|
||||
clearActivityLog,
|
||||
getMessagesByType,
|
||||
getRecentMessages,
|
||||
cleanup,
|
||||
|
||||
// Computed
|
||||
isMessageLimitReached,
|
||||
shouldShowMessageWarning,
|
||||
connectionHealthColor,
|
||||
connectionHealthText
|
||||
}
|
||||
}
|
||||
22
examples/clientsocket/nuxt.config.ts
Normal file
22
examples/clientsocket/nuxt.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export default defineNuxtConfig({
|
||||
devtools: { enabled: true },
|
||||
modules: [],
|
||||
css: ['~/assets/css/main.css', 'vuetify/styles'],
|
||||
build: {
|
||||
transpile: ['vuetify']
|
||||
},
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
wsUrl: 'ws://localhost:8080/api/v1/ws'
|
||||
}
|
||||
},
|
||||
typescript: {
|
||||
typeCheck: false
|
||||
},
|
||||
vite: {
|
||||
define: {
|
||||
global: 'globalThis'
|
||||
}
|
||||
},
|
||||
compatibilityDate: '2024-04-03'
|
||||
})
|
||||
11504
examples/clientsocket/package-lock.json
generated
Normal file
11504
examples/clientsocket/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
examples/clientsocket/package.json
Normal file
27
examples/clientsocket/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "nuxt3-websocket-client",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/devtools": "latest",
|
||||
"nuxt": "^3.8.0",
|
||||
"vue": "^3.3.8",
|
||||
"vue-router": "^4.2.5",
|
||||
"vue-tsc": "^3.0.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "^7.3.67",
|
||||
"@nuxtjs/vuetify": "^1.12.3",
|
||||
"highlight.js": "^11.9.0",
|
||||
"pinia": "^2.1.7",
|
||||
"vue3-highlightjs": "^1.0.5",
|
||||
"vuetify": "^3.4.0"
|
||||
}
|
||||
}
|
||||
14
examples/clientsocket/pages/index.vue
Normal file
14
examples/clientsocket/pages/index.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div>
|
||||
<WebSocketClient />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Main page for the WebSocket client application
|
||||
// This page serves as the entry point and displays the WebSocket client interface
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Additional page-specific styles if needed */
|
||||
</style>
|
||||
15
examples/clientsocket/plugins/vuetify.js
Normal file
15
examples/clientsocket/plugins/vuetify.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createVuetify } from 'vuetify'
|
||||
import * as components from 'vuetify/components'
|
||||
import * as directives from 'vuetify/directives'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const vuetify = createVuetify({
|
||||
components,
|
||||
directives,
|
||||
theme: {
|
||||
defaultTheme: 'light'
|
||||
}
|
||||
})
|
||||
|
||||
nuxtApp.vueApp.use(vuetify)
|
||||
})
|
||||
BIN
examples/clientsocket/public/favicon.ico
Normal file
BIN
examples/clientsocket/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
2
examples/clientsocket/public/robots.txt
Normal file
2
examples/clientsocket/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-Agent: *
|
||||
Disallow:
|
||||
3
examples/clientsocket/tsconfig.json
Normal file
3
examples/clientsocket/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
182
examples/clientsocket/types/websocket.ts
Normal file
182
examples/clientsocket/types/websocket.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
export interface WebSocketMessage {
|
||||
type: string;
|
||||
data: any;
|
||||
timestamp?: number;
|
||||
client_id?: string;
|
||||
message_id?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionInfo {
|
||||
client_id: string;
|
||||
static_id: string;
|
||||
ip_address: string;
|
||||
room: string;
|
||||
user_id: string;
|
||||
connected_at: number;
|
||||
id_type: string;
|
||||
}
|
||||
|
||||
export interface ClientInfo {
|
||||
id: string;
|
||||
static_id: string;
|
||||
ip_address: string;
|
||||
user_id: string;
|
||||
room: string;
|
||||
connected_at: number;
|
||||
last_ping: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface OnlineUser {
|
||||
client_id: string;
|
||||
static_id: string;
|
||||
user_id: string;
|
||||
room: string;
|
||||
ip_address: string;
|
||||
connected_at: number;
|
||||
last_ping: number;
|
||||
}
|
||||
|
||||
export interface ConnectionStats {
|
||||
connected_clients: number;
|
||||
unique_ips: number;
|
||||
static_clients: number;
|
||||
active_rooms: number;
|
||||
ip_distribution: Record<string, number>;
|
||||
room_distribution: Record<string, number>;
|
||||
message_queue_size: number;
|
||||
queue_workers: number;
|
||||
uptime: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface SystemHealth {
|
||||
databases: any;
|
||||
available_dbs: string[];
|
||||
websocket_status: string;
|
||||
uptime_seconds: number;
|
||||
}
|
||||
|
||||
export interface PerformanceMetrics {
|
||||
messages_per_second: number;
|
||||
average_latency_ms: number;
|
||||
error_rate_percent: number;
|
||||
memory_usage_bytes: number;
|
||||
}
|
||||
|
||||
export interface MonitoringData {
|
||||
stats: ConnectionStats;
|
||||
recent_activity: ActivityLog[];
|
||||
system_health: SystemHealth;
|
||||
performance: PerformanceMetrics;
|
||||
}
|
||||
|
||||
export interface ActivityLog {
|
||||
timestamp: number;
|
||||
event: string;
|
||||
client_id: string;
|
||||
details: string;
|
||||
}
|
||||
|
||||
export interface MessageHistory {
|
||||
timestamp: Date;
|
||||
type: string;
|
||||
data: any;
|
||||
messageId?: string;
|
||||
size: number;
|
||||
icon?: string;
|
||||
timeString?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionState {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
connectionStatus: "disconnected" | "connecting" | "connected" | "error";
|
||||
clientId: string | null;
|
||||
staticId: string | null;
|
||||
currentRoom: string | null;
|
||||
userId: string;
|
||||
ipAddress: string | null;
|
||||
connectionStartTime: number | null;
|
||||
lastPingTime: number | null;
|
||||
connectionLatency: number;
|
||||
connectionHealth: "poor" | "warning" | "good" | "excellent";
|
||||
reconnectAttempts: number;
|
||||
messagesReceived: number;
|
||||
messagesSent: number;
|
||||
uptime: string;
|
||||
}
|
||||
|
||||
export interface WebSocketConfig {
|
||||
wsUrl: string;
|
||||
userId: string;
|
||||
room: string;
|
||||
staticId?: string;
|
||||
useIPBasedId?: boolean;
|
||||
autoReconnect: boolean;
|
||||
heartbeatEnabled: boolean;
|
||||
maxReconnectAttempts: number;
|
||||
reconnectDelay: number;
|
||||
maxReconnectDelay: number;
|
||||
heartbeatInterval: number;
|
||||
heartbeatTimeout: number;
|
||||
maxMissedHeartbeats: number;
|
||||
maxMessages: number;
|
||||
messageWarningThreshold: number;
|
||||
actionThrottle: number;
|
||||
}
|
||||
|
||||
export type MessageType =
|
||||
| "welcome"
|
||||
| "broadcast"
|
||||
| "direct_message"
|
||||
| "room_message"
|
||||
| "ping"
|
||||
| "pong"
|
||||
| "heartbeat"
|
||||
| "heartbeat_ack"
|
||||
| "connection_test"
|
||||
| "connection_test_result"
|
||||
| "get_online_users"
|
||||
| "online_users"
|
||||
| "get_server_info"
|
||||
| "server_info"
|
||||
| "error"
|
||||
| "message_received"
|
||||
| "broadcast_sent"
|
||||
| "direct_message_sent"
|
||||
| "room_message_sent"
|
||||
| "db_insert"
|
||||
| "db_query"
|
||||
| "db_custom_query"
|
||||
| "query_result"
|
||||
| "admin_kick_client"
|
||||
| "admin_kill_server"
|
||||
| "get_server_stats"
|
||||
| "get_system_health"
|
||||
| "admin_clear_logs"
|
||||
| "get_stats"
|
||||
| "get_room_info"
|
||||
| "join_room"
|
||||
| "leave_room"
|
||||
| "database_change"
|
||||
| "data_stream"
|
||||
| "server_heartbeat"
|
||||
| "system_status"
|
||||
| "clients_by_ip"
|
||||
| "client_info"
|
||||
| "get_clients_by_ip"
|
||||
| "get_client_info"
|
||||
| "health_check"
|
||||
| "database_list"
|
||||
| "connection_stats"
|
||||
| "trigger_notification"
|
||||
| "notification_sent"
|
||||
| "API_TEST"
|
||||
| "manual_test"
|
||||
| "retribusi_created"
|
||||
| "retribusi_updated"
|
||||
| "retribusi_deleted"
|
||||
| "peserta_changes"
|
||||
| "retribusi_changes"
|
||||
| "system_changes";
|
||||
90
go.mod
Normal file
90
go.mod
Normal file
@@ -0,0 +1,90 @@
|
||||
module api-service
|
||||
|
||||
go 1.24.4
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
github.com/jackc/pgx/v5 v5.7.2 // Ensure pgx is a direct dependency
|
||||
go.mongodb.org/mongo-driver v1.17.3
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/sync v0.16.0
|
||||
gorm.io/driver/mysql v1.6.0 // GORM MySQL driver
|
||||
gorm.io/driver/postgres v1.5.11 // Added GORM PostgreSQL driver
|
||||
gorm.io/driver/sqlserver v1.6.1 // GORM SQL Server driver
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/daku10/go-lz-string v0.0.6
|
||||
github.com/go-playground/validator/v10 v10.27.0
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/mashingan/smapping v0.1.19
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.0
|
||||
github.com/swaggo/swag v1.16.6
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
||||
github.com/go-openapi/spec v0.20.4 // indirect
|
||||
github.com/go-openapi/swag v0.19.15 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.8.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.1.2 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/mod v0.26.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
golang.org/x/tools v0.35.0 // indirect
|
||||
google.golang.org/protobuf v1.36.7 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/gorm v1.30.0 // indirect
|
||||
)
|
||||
361
go.sum
Normal file
361
go.sum
Normal file
@@ -0,0 +1,361 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 h1:U2rTu3Ef+7w9FHKIAXM6ZyqF3UOWJZ12zIm8zECAFfg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/daku10/go-lz-string v0.0.6 h1:aO8FFp4QPuNp7+WNyh1DyNjGF3UbZu95tUv9xOZNsYQ=
|
||||
github.com/daku10/go-lz-string v0.0.6/go.mod h1:Vk++rSG3db8HXJaHEAbxiy/ukjTmPBw/iI+SrVZDzfs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
|
||||
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
||||
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
|
||||
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
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.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
||||
github.com/jackc/pgx/v5 v5.7.2/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/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mashingan/smapping v0.1.19 h1:SsEtuPn2UcM1croIupPtGLgWgpYRuS0rSQMvKD9g2BQ=
|
||||
github.com/mashingan/smapping v0.1.19/go.mod h1:FjfiwFxGOuNxL/OT1WcrNAwTPx0YJeg5JiXwBB1nyig=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
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/microsoft/go-mssqldb v1.8.2 h1:236sewazvC8FvG6Dr3bszrVhMkAl4KYImryLkRMCd0I=
|
||||
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
|
||||
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/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
|
||||
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
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/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.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.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/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
|
||||
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
|
||||
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
|
||||
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
|
||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
|
||||
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
||||
google.golang.org/protobuf v1.36.7/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-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/driver/sqlserver v1.6.1 h1:XWISFsu2I2pqd1KJhhTZNJMx1jNQ+zVL/Q8ovDcUjtY=
|
||||
gorm.io/driver/sqlserver v1.6.1/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
|
||||
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
739
internal/config/config.go
Normal file
739
internal/config/config.go
Normal file
@@ -0,0 +1,739 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig
|
||||
Databases map[string]DatabaseConfig
|
||||
ReadReplicas map[string][]DatabaseConfig // For read replicas
|
||||
Keycloak KeycloakConfig
|
||||
Bpjs BpjsConfig
|
||||
SatuSehat SatuSehatConfig
|
||||
Swagger SwaggerConfig
|
||||
Validator *validator.Validate
|
||||
}
|
||||
|
||||
type SwaggerConfig struct {
|
||||
Title string
|
||||
Description string
|
||||
Version string
|
||||
TermsOfService string
|
||||
ContactName string
|
||||
ContactURL string
|
||||
ContactEmail string
|
||||
LicenseName string
|
||||
LicenseURL string
|
||||
Host string
|
||||
BasePath string
|
||||
Schemes []string
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int
|
||||
Mode string
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Name string
|
||||
Type string // postgres, mysql, sqlserver, sqlite, mongodb
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
Database string
|
||||
Schema string
|
||||
SSLMode string
|
||||
Path string // For SQLite
|
||||
Options string // Additional connection options
|
||||
MaxOpenConns int // Max open connections
|
||||
MaxIdleConns int // Max idle connections
|
||||
ConnMaxLifetime time.Duration // Connection max lifetime
|
||||
}
|
||||
|
||||
type KeycloakConfig struct {
|
||||
Issuer string
|
||||
Audience string
|
||||
JwksURL string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
type BpjsConfig struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
ConsID string `json:"cons_id"`
|
||||
UserKey string `json:"user_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
}
|
||||
|
||||
type SatuSehatConfig struct {
|
||||
OrgID string `json:"org_id"`
|
||||
FasyakesID string `json:"fasyakes_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
AuthURL string `json:"auth_url"`
|
||||
BaseURL string `json:"base_url"`
|
||||
ConsentURL string `json:"consent_url"`
|
||||
KFAURL string `json:"kfa_url"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
}
|
||||
|
||||
// SetHeader generates required headers for BPJS VClaim API
|
||||
// func (cfg BpjsConfig) SetHeader() (string, string, string, string, string) {
|
||||
// timenow := time.Now().UTC()
|
||||
// t, err := time.Parse(time.RFC3339, "1970-01-01T00:00:00Z")
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
|
||||
// tstamp := timenow.Unix() - t.Unix()
|
||||
// secret := []byte(cfg.SecretKey)
|
||||
// message := []byte(cfg.ConsID + "&" + fmt.Sprint(tstamp))
|
||||
// hash := hmac.New(sha256.New, secret)
|
||||
// hash.Write(message)
|
||||
|
||||
// // to lowercase hexits
|
||||
// hex.EncodeToString(hash.Sum(nil))
|
||||
// // to base64
|
||||
// xSignature := base64.StdEncoding.EncodeToString(hash.Sum(nil))
|
||||
|
||||
// return cfg.ConsID, cfg.SecretKey, cfg.UserKey, fmt.Sprint(tstamp), xSignature
|
||||
// }
|
||||
func (cfg BpjsConfig) SetHeader() (string, string, string, string, string) {
|
||||
timenow := time.Now().UTC()
|
||||
t, err := time.Parse(time.RFC3339, "1970-01-01T00:00:00Z")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
tstamp := timenow.Unix() - t.Unix()
|
||||
secret := []byte(cfg.SecretKey)
|
||||
message := []byte(cfg.ConsID + "&" + fmt.Sprint(tstamp))
|
||||
hash := hmac.New(sha256.New, secret)
|
||||
hash.Write(message)
|
||||
|
||||
// to lowercase hexits
|
||||
hex.EncodeToString(hash.Sum(nil))
|
||||
// to base64
|
||||
xSignature := base64.StdEncoding.EncodeToString(hash.Sum(nil))
|
||||
|
||||
return cfg.ConsID, cfg.SecretKey, cfg.UserKey, fmt.Sprint(tstamp), xSignature
|
||||
}
|
||||
|
||||
type ConfigBpjs struct {
|
||||
Cons_id string
|
||||
Secret_key string
|
||||
User_key string
|
||||
}
|
||||
|
||||
// SetHeader for backward compatibility
|
||||
func (cfg ConfigBpjs) SetHeader() (string, string, string, string, string) {
|
||||
bpjsConfig := BpjsConfig{
|
||||
ConsID: cfg.Cons_id,
|
||||
SecretKey: cfg.Secret_key,
|
||||
UserKey: cfg.User_key,
|
||||
}
|
||||
return bpjsConfig.SetHeader()
|
||||
}
|
||||
|
||||
func LoadConfig() *Config {
|
||||
config := &Config{
|
||||
Server: ServerConfig{
|
||||
Port: getEnvAsInt("PORT", 8080),
|
||||
Mode: getEnv("GIN_MODE", "debug"),
|
||||
},
|
||||
Databases: make(map[string]DatabaseConfig),
|
||||
ReadReplicas: make(map[string][]DatabaseConfig),
|
||||
Keycloak: KeycloakConfig{
|
||||
Issuer: getEnv("KEYCLOAK_ISSUER", "https://keycloak.example.com/auth/realms/yourrealm"),
|
||||
Audience: getEnv("KEYCLOAK_AUDIENCE", "your-client-id"),
|
||||
JwksURL: getEnv("KEYCLOAK_JWKS_URL", "https://keycloak.example.com/auth/realms/yourrealm/protocol/openid-connect/certs"),
|
||||
Enabled: getEnvAsBool("KEYCLOAK_ENABLED", true),
|
||||
},
|
||||
Bpjs: BpjsConfig{
|
||||
BaseURL: getEnv("BPJS_BASEURL", "https://apijkn.bpjs-kesehatan.go.id"),
|
||||
ConsID: getEnv("BPJS_CONSID", ""),
|
||||
UserKey: getEnv("BPJS_USERKEY", ""),
|
||||
SecretKey: getEnv("BPJS_SECRETKEY", ""),
|
||||
Timeout: parseDuration(getEnv("BPJS_TIMEOUT", "30s")),
|
||||
},
|
||||
SatuSehat: SatuSehatConfig{
|
||||
OrgID: getEnv("BRIDGING_SATUSEHAT_ORG_ID", ""),
|
||||
FasyakesID: getEnv("BRIDGING_SATUSEHAT_FASYAKES_ID", ""),
|
||||
ClientID: getEnv("BRIDGING_SATUSEHAT_CLIENT_ID", ""),
|
||||
ClientSecret: getEnv("BRIDGING_SATUSEHAT_CLIENT_SECRET", ""),
|
||||
AuthURL: getEnv("BRIDGING_SATUSEHAT_AUTH_URL", "https://api-satusehat.kemkes.go.id/oauth2/v1"),
|
||||
BaseURL: getEnv("BRIDGING_SATUSEHAT_BASE_URL", "https://api-satusehat.kemkes.go.id/fhir-r4/v1"),
|
||||
ConsentURL: getEnv("BRIDGING_SATUSEHAT_CONSENT_URL", "https://api-satusehat.dto.kemkes.go.id/consent/v1"),
|
||||
KFAURL: getEnv("BRIDGING_SATUSEHAT_KFA_URL", "https://api-satusehat.kemkes.go.id/kfa-v2"),
|
||||
Timeout: parseDuration(getEnv("BRIDGING_SATUSEHAT_TIMEOUT", "30s")),
|
||||
},
|
||||
Swagger: SwaggerConfig{
|
||||
Title: getEnv("SWAGGER_TITLE", "SERVICE API"),
|
||||
Description: getEnv("SWAGGER_DESCRIPTION", "CUSTUM SERVICE API"),
|
||||
Version: getEnv("SWAGGER_VERSION", "1.0.0"),
|
||||
TermsOfService: getEnv("SWAGGER_TERMS_OF_SERVICE", "http://swagger.io/terms/"),
|
||||
ContactName: getEnv("SWAGGER_CONTACT_NAME", "API Support"),
|
||||
ContactURL: getEnv("SWAGGER_CONTACT_URL", "http://rssa.example.com/support"),
|
||||
ContactEmail: getEnv("SWAGGER_CONTACT_EMAIL", "support@swagger.io"),
|
||||
LicenseName: getEnv("SWAGGER_LICENSE_NAME", "Apache 2.0"),
|
||||
LicenseURL: getEnv("SWAGGER_LICENSE_URL", "http://www.apache.org/licenses/LICENSE-2.0.html"),
|
||||
Host: getEnv("SWAGGER_HOST", "localhost:8080"),
|
||||
BasePath: getEnv("SWAGGER_BASE_PATH", "/api/v1"),
|
||||
Schemes: parseSchemes(getEnv("SWAGGER_SCHEMES", "http,https")),
|
||||
},
|
||||
}
|
||||
|
||||
// Initialize validator
|
||||
config.Validator = validator.New()
|
||||
|
||||
// Load database configurations
|
||||
config.loadDatabaseConfigs()
|
||||
|
||||
// Load read replica configurations
|
||||
config.loadReadReplicaConfigs()
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func (c *Config) loadDatabaseConfigs() {
|
||||
// Simplified approach: Directly load from environment variables
|
||||
// This ensures we get the exact values specified in .env
|
||||
|
||||
// Primary database configuration
|
||||
c.Databases["default"] = DatabaseConfig{
|
||||
Name: "default",
|
||||
Type: getEnv("DB_CONNECTION", "postgres"),
|
||||
Host: getEnv("DB_HOST", "localhost"),
|
||||
Port: getEnvAsInt("DB_PORT", 5432),
|
||||
Username: getEnv("DB_USERNAME", ""),
|
||||
Password: getEnv("DB_PASSWORD", ""),
|
||||
Database: getEnv("DB_DATABASE", "satu_db"),
|
||||
Schema: getEnv("DB_SCHEMA", "public"),
|
||||
SSLMode: getEnv("DB_SSLMODE", "disable"),
|
||||
MaxOpenConns: getEnvAsInt("DB_MAX_OPEN_CONNS", 25),
|
||||
MaxIdleConns: getEnvAsInt("DB_MAX_IDLE_CONNS", 25),
|
||||
ConnMaxLifetime: parseDuration(getEnv("DB_CONN_MAX_LIFETIME", "5m")),
|
||||
}
|
||||
|
||||
// SATUDATA database configuration
|
||||
c.addPostgreSQLConfigs()
|
||||
|
||||
// MongoDB database configuration
|
||||
c.addMongoDBConfigs()
|
||||
|
||||
// Legacy support for backward compatibility
|
||||
envVars := os.Environ()
|
||||
dbConfigs := make(map[string]map[string]string)
|
||||
|
||||
// Parse database configurations from environment variables
|
||||
for _, envVar := range envVars {
|
||||
parts := strings.SplitN(envVar, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := parts[0]
|
||||
value := parts[1]
|
||||
|
||||
// Parse specific database configurations
|
||||
if strings.HasSuffix(key, "_CONNECTION") || strings.HasSuffix(key, "_HOST") ||
|
||||
strings.HasSuffix(key, "_DATABASE") || strings.HasSuffix(key, "_USERNAME") ||
|
||||
strings.HasSuffix(key, "_PASSWORD") || strings.HasSuffix(key, "_PORT") ||
|
||||
strings.HasSuffix(key, "_NAME") {
|
||||
|
||||
segments := strings.Split(key, "_")
|
||||
if len(segments) >= 2 {
|
||||
dbName := strings.ToLower(strings.Join(segments[:len(segments)-1], "_"))
|
||||
property := strings.ToLower(segments[len(segments)-1])
|
||||
|
||||
if dbConfigs[dbName] == nil {
|
||||
dbConfigs[dbName] = make(map[string]string)
|
||||
}
|
||||
dbConfigs[dbName][property] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create DatabaseConfig from parsed configurations for additional databases
|
||||
for name, config := range dbConfigs {
|
||||
// Skip empty configurations or system configurations
|
||||
if name == "" || strings.Contains(name, "chrome_crashpad_pipe") || name == "primary" {
|
||||
continue
|
||||
}
|
||||
|
||||
dbConfig := DatabaseConfig{
|
||||
Name: name,
|
||||
Type: getEnvFromMap(config, "connection", getEnvFromMap(config, "type", "postgres")),
|
||||
Host: getEnvFromMap(config, "host", "localhost"),
|
||||
Port: getEnvAsIntFromMap(config, "port", 5432),
|
||||
Username: getEnvFromMap(config, "username", ""),
|
||||
Password: getEnvFromMap(config, "password", ""),
|
||||
Database: getEnvFromMap(config, "database", getEnvFromMap(config, "name", name)),
|
||||
Schema: getEnvFromMap(config, "schema", "public"),
|
||||
SSLMode: getEnvFromMap(config, "sslmode", "disable"),
|
||||
Path: getEnvFromMap(config, "path", ""),
|
||||
Options: getEnvFromMap(config, "options", ""),
|
||||
MaxOpenConns: getEnvAsIntFromMap(config, "max_open_conns", 25),
|
||||
MaxIdleConns: getEnvAsIntFromMap(config, "max_idle_conns", 25),
|
||||
ConnMaxLifetime: parseDuration(getEnvFromMap(config, "conn_max_lifetime", "5m")),
|
||||
}
|
||||
|
||||
// Skip if username is empty and it's not a system config
|
||||
if dbConfig.Username == "" && !strings.HasPrefix(name, "chrome") {
|
||||
continue
|
||||
}
|
||||
|
||||
c.Databases[name] = dbConfig
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) loadReadReplicaConfigs() {
|
||||
envVars := os.Environ()
|
||||
|
||||
for _, envVar := range envVars {
|
||||
parts := strings.SplitN(envVar, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := parts[0]
|
||||
value := parts[1]
|
||||
|
||||
// Parse read replica configurations (format: [DBNAME]_REPLICA_[INDEX]_[PROPERTY])
|
||||
if strings.Contains(key, "_REPLICA_") {
|
||||
segments := strings.Split(key, "_")
|
||||
if len(segments) >= 5 && strings.ToUpper(segments[2]) == "REPLICA" {
|
||||
dbName := strings.ToLower(segments[1])
|
||||
replicaIndex := segments[3]
|
||||
property := strings.ToLower(strings.Join(segments[4:], "_"))
|
||||
|
||||
replicaKey := dbName + "_replica_" + replicaIndex
|
||||
|
||||
if c.ReadReplicas[dbName] == nil {
|
||||
c.ReadReplicas[dbName] = []DatabaseConfig{}
|
||||
}
|
||||
|
||||
// Find or create replica config
|
||||
var replicaConfig *DatabaseConfig
|
||||
for i := range c.ReadReplicas[dbName] {
|
||||
if c.ReadReplicas[dbName][i].Name == replicaKey {
|
||||
replicaConfig = &c.ReadReplicas[dbName][i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if replicaConfig == nil {
|
||||
// Create new replica config
|
||||
newConfig := DatabaseConfig{
|
||||
Name: replicaKey,
|
||||
Type: c.Databases[dbName].Type,
|
||||
Host: getEnv("DB_"+strings.ToUpper(dbName)+"_REPLICA_"+replicaIndex+"_HOST", c.Databases[dbName].Host),
|
||||
Port: getEnvAsInt("DB_"+strings.ToUpper(dbName)+"_REPLICA_"+replicaIndex+"_PORT", c.Databases[dbName].Port),
|
||||
Username: getEnv("DB_"+strings.ToUpper(dbName)+"_REPLICA_"+replicaIndex+"_USERNAME", c.Databases[dbName].Username),
|
||||
Password: getEnv("DB_"+strings.ToUpper(dbName)+"_REPLICA_"+replicaIndex+"_PASSWORD", c.Databases[dbName].Password),
|
||||
Database: getEnv("DB_"+strings.ToUpper(dbName)+"_REPLICA_"+replicaIndex+"_DATABASE", c.Databases[dbName].Database),
|
||||
Schema: getEnv("DB_"+strings.ToUpper(dbName)+"_REPLICA_"+replicaIndex+"_SCHEMA", c.Databases[dbName].Schema),
|
||||
SSLMode: getEnv("DB_"+strings.ToUpper(dbName)+"_REPLICA_"+replicaIndex+"_SSLMODE", c.Databases[dbName].SSLMode),
|
||||
MaxOpenConns: getEnvAsInt("DB_"+strings.ToUpper(dbName)+"_REPLICA_"+replicaIndex+"_MAX_OPEN_CONNS", c.Databases[dbName].MaxOpenConns),
|
||||
MaxIdleConns: getEnvAsInt("DB_"+strings.ToUpper(dbName)+"_REPLICA_"+replicaIndex+"_MAX_IDLE_CONNS", c.Databases[dbName].MaxIdleConns),
|
||||
ConnMaxLifetime: parseDuration(getEnv("DB_"+strings.ToUpper(dbName)+"_REPLICA_"+replicaIndex+"_CONN_MAX_LIFETIME", "5m")),
|
||||
}
|
||||
c.ReadReplicas[dbName] = append(c.ReadReplicas[dbName], newConfig)
|
||||
replicaConfig = &c.ReadReplicas[dbName][len(c.ReadReplicas[dbName])-1]
|
||||
}
|
||||
|
||||
// Update the specific replica
|
||||
switch property {
|
||||
case "host":
|
||||
replicaConfig.Host = value
|
||||
case "port":
|
||||
replicaConfig.Port = getEnvAsInt(key, 5432)
|
||||
case "username":
|
||||
replicaConfig.Username = value
|
||||
case "password":
|
||||
replicaConfig.Password = value
|
||||
case "database":
|
||||
replicaConfig.Database = value
|
||||
case "schema":
|
||||
replicaConfig.Schema = value
|
||||
case "sslmode":
|
||||
replicaConfig.SSLMode = value
|
||||
case "max_open_conns":
|
||||
replicaConfig.MaxOpenConns = getEnvAsInt(key, 25)
|
||||
case "max_idle_conns":
|
||||
replicaConfig.MaxIdleConns = getEnvAsInt(key, 25)
|
||||
case "conn_max_lifetime":
|
||||
replicaConfig.ConnMaxLifetime = parseDuration(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) addSpecificDatabase(prefix, defaultType string) {
|
||||
connection := getEnv(strings.ToUpper(prefix)+"_CONNECTION", defaultType)
|
||||
host := getEnv(strings.ToUpper(prefix)+"_HOST", "")
|
||||
if host != "" {
|
||||
dbConfig := DatabaseConfig{
|
||||
Name: prefix,
|
||||
Type: connection,
|
||||
Host: host,
|
||||
Port: getEnvAsInt(strings.ToUpper(prefix)+"_PORT", 5432),
|
||||
Username: getEnv(strings.ToUpper(prefix)+"_USERNAME", ""),
|
||||
Password: getEnv(strings.ToUpper(prefix)+"_PASSWORD", ""),
|
||||
Database: getEnv(strings.ToUpper(prefix)+"_DATABASE", getEnv(strings.ToUpper(prefix)+"_NAME", prefix)),
|
||||
Schema: getEnv(strings.ToUpper(prefix)+"_SCHEMA", "public"),
|
||||
SSLMode: getEnv(strings.ToUpper(prefix)+"_SSLMODE", "disable"),
|
||||
MaxOpenConns: getEnvAsInt(strings.ToUpper(prefix)+"_MAX_OPEN_CONNS", 25),
|
||||
MaxIdleConns: getEnvAsInt(strings.ToUpper(prefix)+"_MAX_IDLE_CONNS", 25),
|
||||
ConnMaxLifetime: parseDuration(getEnv(strings.ToUpper(prefix)+"_CONN_MAX_LIFETIME", "5m")),
|
||||
}
|
||||
c.Databases[prefix] = dbConfig
|
||||
}
|
||||
}
|
||||
|
||||
// PostgreSQL database
|
||||
func (c *Config) addPostgreSQLConfigs() {
|
||||
// SATUDATA database configuration
|
||||
// defaultPOSTGRESHost := getEnv("POSTGRES_HOST", "localhost")
|
||||
// if defaultPOSTGRESHost != "" {
|
||||
// c.Databases["postgres"] = DatabaseConfig{
|
||||
// Name: "postgres",
|
||||
// Type: getEnv("POSTGRES_CONNECTION", "postgres"),
|
||||
// Host: defaultPOSTGRESHost,
|
||||
// Port: getEnvAsInt("POSTGRES_PORT", 5432),
|
||||
// Username: getEnv("POSTGRES_USERNAME", ""),
|
||||
// Password: getEnv("POSTGRES_PASSWORD", ""),
|
||||
// Database: getEnv("POSTGRES_DATABASE", "postgres"),
|
||||
// Schema: getEnv("POSTGRES_SCHEMA", "public"),
|
||||
// SSLMode: getEnv("POSTGRES_SSLMODE", "disable"),
|
||||
// MaxOpenConns: getEnvAsInt("POSTGRES_MAX_OPEN_CONNS", 25),
|
||||
// MaxIdleConns: getEnvAsInt("POSTGRES_MAX_IDLE_CONNS", 25),
|
||||
// ConnMaxLifetime: parseDuration(getEnv("POSTGRES_CONN_MAX_LIFETIME", "5m")),
|
||||
// }
|
||||
// }
|
||||
|
||||
// Support for custom PostgreSQL configurations with POSTGRES_ prefix
|
||||
envVars := os.Environ()
|
||||
for _, envVar := range envVars {
|
||||
parts := strings.SplitN(envVar, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := parts[0]
|
||||
// Parse PostgreSQL configurations (format: POSTGRES_[NAME]_[PROPERTY])
|
||||
if strings.HasPrefix(key, "POSTGRES_") && strings.Contains(key, "_") {
|
||||
segments := strings.Split(key, "_")
|
||||
if len(segments) >= 3 {
|
||||
dbName := strings.ToLower(strings.Join(segments[1:len(segments)-1], "_"))
|
||||
|
||||
// Skip if it's a standard PostgreSQL configuration
|
||||
if dbName == "connection" || dbName == "dev" || dbName == "default" || dbName == "satudata" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create or update PostgreSQL configuration
|
||||
if _, exists := c.Databases[dbName]; !exists {
|
||||
c.Databases[dbName] = DatabaseConfig{
|
||||
Name: dbName,
|
||||
Type: "postgres",
|
||||
Host: getEnv("POSTGRES_"+strings.ToUpper(dbName)+"_HOST", "localhost"),
|
||||
Port: getEnvAsInt("POSTGRES_"+strings.ToUpper(dbName)+"_PORT", 5432),
|
||||
Username: getEnv("POSTGRES_"+strings.ToUpper(dbName)+"_USERNAME", ""),
|
||||
Password: getEnv("POSTGRES_"+strings.ToUpper(dbName)+"_PASSWORD", ""),
|
||||
Database: getEnv("POSTGRES_"+strings.ToUpper(dbName)+"_DATABASE", dbName),
|
||||
Schema: getEnv("POSTGRES_"+strings.ToUpper(dbName)+"_SCHEMA", "public"),
|
||||
SSLMode: getEnv("POSTGRES_"+strings.ToUpper(dbName)+"_SSLMODE", "disable"),
|
||||
MaxOpenConns: getEnvAsInt("POSTGRES_MAX_OPEN_CONNS", 25),
|
||||
MaxIdleConns: getEnvAsInt("POSTGRES_MAX_IDLE_CONNS", 25),
|
||||
ConnMaxLifetime: parseDuration(getEnv("POSTGRES_CONN_MAX_LIFETIME", "5m")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addMYSQLConfigs adds MYSQL database
|
||||
func (c *Config) addMySQLConfigs() {
|
||||
// Primary MySQL configuration
|
||||
defaultMySQLHost := getEnv("MYSQL_HOST", "")
|
||||
if defaultMySQLHost != "" {
|
||||
c.Databases["mysql"] = DatabaseConfig{
|
||||
Name: "mysql",
|
||||
Type: getEnv("MYSQL_CONNECTION", "mysql"),
|
||||
Host: defaultMySQLHost,
|
||||
Port: getEnvAsInt("MYSQL_PORT", 3306),
|
||||
Username: getEnv("MYSQL_USERNAME", ""),
|
||||
Password: getEnv("MYSQL_PASSWORD", ""),
|
||||
Database: getEnv("MYSQL_DATABASE", "mysql"),
|
||||
SSLMode: getEnv("MYSQL_SSLMODE", "disable"),
|
||||
MaxOpenConns: getEnvAsInt("MYSQL_MAX_OPEN_CONNS", 25),
|
||||
MaxIdleConns: getEnvAsInt("MYSQL_MAX_IDLE_CONNS", 25),
|
||||
ConnMaxLifetime: parseDuration(getEnv("MYSQL_CONN_MAX_LIFETIME", "5m")),
|
||||
}
|
||||
}
|
||||
|
||||
// Support for custom MySQL configurations with MYSQL_ prefix
|
||||
envVars := os.Environ()
|
||||
for _, envVar := range envVars {
|
||||
parts := strings.SplitN(envVar, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := parts[0]
|
||||
// Parse MySQL configurations (format: MYSQL_[NAME]_[PROPERTY])
|
||||
if strings.HasPrefix(key, "MYSQL_") && strings.Contains(key, "_") {
|
||||
segments := strings.Split(key, "_")
|
||||
if len(segments) >= 3 {
|
||||
dbName := strings.ToLower(strings.Join(segments[1:len(segments)-1], "_"))
|
||||
|
||||
// Skip if it's a standard MySQL configuration
|
||||
if dbName == "connection" || dbName == "dev" || dbName == "max" || dbName == "conn" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create or update MySQL configuration
|
||||
if _, exists := c.Databases[dbName]; !exists {
|
||||
mysqlHost := getEnv("MYSQL_"+strings.ToUpper(dbName)+"_HOST", "")
|
||||
if mysqlHost != "" {
|
||||
c.Databases[dbName] = DatabaseConfig{
|
||||
Name: dbName,
|
||||
Type: getEnv("MYSQL_"+strings.ToUpper(dbName)+"_CONNECTION", "mysql"),
|
||||
Host: mysqlHost,
|
||||
Port: getEnvAsInt("MYSQL_"+strings.ToUpper(dbName)+"_PORT", 3306),
|
||||
Username: getEnv("MYSQL_"+strings.ToUpper(dbName)+"_USERNAME", ""),
|
||||
Password: getEnv("MYSQL_"+strings.ToUpper(dbName)+"_PASSWORD", ""),
|
||||
Database: getEnv("MYSQL_"+strings.ToUpper(dbName)+"_DATABASE", dbName),
|
||||
SSLMode: getEnv("MYSQL_"+strings.ToUpper(dbName)+"_SSLMODE", "disable"),
|
||||
MaxOpenConns: getEnvAsInt("MYSQL_MAX_OPEN_CONNS", 25),
|
||||
MaxIdleConns: getEnvAsInt("MYSQL_MAX_IDLE_CONNS", 25),
|
||||
ConnMaxLifetime: parseDuration(getEnv("MYSQL_CONN_MAX_LIFETIME", "5m")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addMongoDBConfigs adds MongoDB database configurations from environment variables
|
||||
func (c *Config) addMongoDBConfigs() {
|
||||
// Primary MongoDB configuration
|
||||
mongoHost := getEnv("MONGODB_HOST", "")
|
||||
if mongoHost != "" {
|
||||
c.Databases["mongodb"] = DatabaseConfig{
|
||||
Name: "mongodb",
|
||||
Type: getEnv("MONGODB_CONNECTION", "mongodb"),
|
||||
Host: mongoHost,
|
||||
Port: getEnvAsInt("MONGODB_PORT", 27017),
|
||||
Username: getEnv("MONGODB_USER", ""),
|
||||
Password: getEnv("MONGODB_PASS", ""),
|
||||
Database: getEnv("MONGODB_MASTER", "master"),
|
||||
SSLMode: getEnv("MONGODB_SSLMODE", "disable"),
|
||||
MaxOpenConns: getEnvAsInt("MONGODB_MAX_OPEN_CONNS", 100),
|
||||
MaxIdleConns: getEnvAsInt("MONGODB_MAX_IDLE_CONNS", 10),
|
||||
ConnMaxLifetime: parseDuration(getEnv("MONGODB_CONN_MAX_LIFETIME", "30m")),
|
||||
}
|
||||
}
|
||||
|
||||
// Additional MongoDB configurations for local database
|
||||
mongoLocalHost := getEnv("MONGODB_LOCAL_HOST", "")
|
||||
if mongoLocalHost != "" {
|
||||
c.Databases["mongodb_local"] = DatabaseConfig{
|
||||
Name: "mongodb_local",
|
||||
Type: getEnv("MONGODB_CONNECTION", "mongodb"),
|
||||
Host: mongoLocalHost,
|
||||
Port: getEnvAsInt("MONGODB_LOCAL_PORT", 27017),
|
||||
Username: getEnv("MONGODB_LOCAL_USER", ""),
|
||||
Password: getEnv("MONGODB_LOCAL_PASS", ""),
|
||||
Database: getEnv("MONGODB_LOCAL_DB", "local"),
|
||||
SSLMode: getEnv("MONGOD_SSLMODE", "disable"),
|
||||
MaxOpenConns: getEnvAsInt("MONGODB_MAX_OPEN_CONNS", 100),
|
||||
MaxIdleConns: getEnvAsInt("MONGODB_MAX_IDLE_CONNS", 10),
|
||||
ConnMaxLifetime: parseDuration(getEnv("MONGODB_CONN_MAX_LIFETIME", "30m")),
|
||||
}
|
||||
}
|
||||
|
||||
// Support for custom MongoDB configurations with MONGODB_ prefix
|
||||
envVars := os.Environ()
|
||||
for _, envVar := range envVars {
|
||||
parts := strings.SplitN(envVar, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := parts[0]
|
||||
// Parse MongoDB configurations (format: MONGODB_[NAME]_[PROPERTY])
|
||||
if strings.HasPrefix(key, "MONGODB_") && strings.Contains(key, "_") {
|
||||
segments := strings.Split(key, "_")
|
||||
if len(segments) >= 3 {
|
||||
dbName := strings.ToLower(strings.Join(segments[1:len(segments)-1], "_"))
|
||||
// Skip if it's a standard MongoDB configuration
|
||||
if dbName == "connection" || dbName == "dev" || dbName == "local" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create or update MongoDB configuration
|
||||
if _, exists := c.Databases[dbName]; !exists {
|
||||
c.Databases[dbName] = DatabaseConfig{
|
||||
Name: dbName,
|
||||
Type: "mongodb",
|
||||
Host: getEnv("MONGODB_"+strings.ToUpper(dbName)+"_HOST", "localhost"),
|
||||
Port: getEnvAsInt("MONGODB_"+strings.ToUpper(dbName)+"_PORT", 27017),
|
||||
Username: getEnv("MONGODB_"+strings.ToUpper(dbName)+"_USER", ""),
|
||||
Password: getEnv("MONGODB_"+strings.ToUpper(dbName)+"_PASS", ""),
|
||||
Database: getEnv("MONGODB_"+strings.ToUpper(dbName)+"_DB", dbName),
|
||||
SSLMode: getEnv("MONGOD_SSLMODE", "disable"),
|
||||
MaxOpenConns: getEnvAsInt("MONGODB_MAX_OPEN_CONNS", 100),
|
||||
MaxIdleConns: getEnvAsInt("MONGODB_MAX_IDLE_CONNS", 10),
|
||||
ConnMaxLifetime: parseDuration(getEnv("MONGODB_CONN_MAX_LIFETIME", "30m")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getEnvFromMap(config map[string]string, key, defaultValue string) string {
|
||||
if value, exists := config[key]; exists {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvAsIntFromMap(config map[string]string, key string, defaultValue int) int {
|
||||
if value, exists := config[key]; exists {
|
||||
if intValue, err := strconv.Atoi(value); err == nil {
|
||||
return intValue
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func parseDuration(durationStr string) time.Duration {
|
||||
if duration, err := time.ParseDuration(durationStr); err == nil {
|
||||
return duration
|
||||
}
|
||||
return 5 * time.Minute
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvAsInt(key string, defaultValue int) int {
|
||||
valueStr := getEnv(key, "")
|
||||
if value, err := strconv.Atoi(valueStr); err == nil {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvAsBool(key string, defaultValue bool) bool {
|
||||
valueStr := getEnv(key, "")
|
||||
if value, err := strconv.ParseBool(valueStr); err == nil {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// parseSchemes parses comma-separated schemes string into a slice
|
||||
func parseSchemes(schemesStr string) []string {
|
||||
if schemesStr == "" {
|
||||
return []string{"http"}
|
||||
}
|
||||
|
||||
schemes := strings.Split(schemesStr, ",")
|
||||
for i, scheme := range schemes {
|
||||
schemes[i] = strings.TrimSpace(scheme)
|
||||
}
|
||||
return schemes
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
if len(c.Databases) == 0 {
|
||||
log.Fatal("At least one database configuration is required")
|
||||
}
|
||||
|
||||
for name, db := range c.Databases {
|
||||
if db.Host == "" {
|
||||
log.Fatalf("Database host is required for %s", name)
|
||||
}
|
||||
if db.Username == "" {
|
||||
log.Fatalf("Database username is required for %s", name)
|
||||
}
|
||||
if db.Password == "" {
|
||||
log.Fatalf("Database password is required for %s", name)
|
||||
}
|
||||
if db.Database == "" {
|
||||
log.Fatalf("Database name is required for %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
if c.Bpjs.BaseURL == "" {
|
||||
log.Fatal("BPJS Base URL is required")
|
||||
}
|
||||
if c.Bpjs.ConsID == "" {
|
||||
log.Fatal("BPJS Consumer ID is required")
|
||||
}
|
||||
if c.Bpjs.UserKey == "" {
|
||||
log.Fatal("BPJS User Key is required")
|
||||
}
|
||||
if c.Bpjs.SecretKey == "" {
|
||||
log.Fatal("BPJS Secret Key is required")
|
||||
}
|
||||
|
||||
// Validate Keycloak configuration if enabled
|
||||
if c.Keycloak.Enabled {
|
||||
if c.Keycloak.Issuer == "" {
|
||||
log.Fatal("Keycloak issuer is required when Keycloak is enabled")
|
||||
}
|
||||
if c.Keycloak.Audience == "" {
|
||||
log.Fatal("Keycloak audience is required when Keycloak is enabled")
|
||||
}
|
||||
if c.Keycloak.JwksURL == "" {
|
||||
log.Fatal("Keycloak JWKS URL is required when Keycloak is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate SatuSehat configuration
|
||||
if c.SatuSehat.OrgID == "" {
|
||||
log.Fatal("SatuSehat Organization ID is required")
|
||||
}
|
||||
if c.SatuSehat.FasyakesID == "" {
|
||||
log.Fatal("SatuSehat Fasyankes ID is required")
|
||||
}
|
||||
if c.SatuSehat.ClientID == "" {
|
||||
log.Fatal("SatuSehat Client ID is required")
|
||||
}
|
||||
if c.SatuSehat.ClientSecret == "" {
|
||||
log.Fatal("SatuSehat Client Secret is required")
|
||||
}
|
||||
if c.SatuSehat.AuthURL == "" {
|
||||
log.Fatal("SatuSehat Auth URL is required")
|
||||
}
|
||||
if c.SatuSehat.BaseURL == "" {
|
||||
log.Fatal("SatuSehat Base URL is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
699
internal/database/database.go
Normal file
699
internal/database/database.go
Normal file
@@ -0,0 +1,699 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log" // Import runtime package
|
||||
|
||||
// Import debug package
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"api-service/internal/config"
|
||||
|
||||
_ "github.com/jackc/pgx/v5" // Import pgx driver
|
||||
"github.com/lib/pq"
|
||||
_ "gorm.io/driver/postgres" // Import GORM PostgreSQL driver
|
||||
|
||||
_ "github.com/go-sql-driver/mysql" // MySQL driver for database/sql
|
||||
_ "gorm.io/driver/mysql" // GORM MySQL driver
|
||||
_ "gorm.io/driver/sqlserver" // GORM SQL Server driver
|
||||
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
// DatabaseType represents supported database types
|
||||
type DatabaseType string
|
||||
|
||||
const (
|
||||
Postgres DatabaseType = "postgres"
|
||||
MySQL DatabaseType = "mysql"
|
||||
SQLServer DatabaseType = "sqlserver"
|
||||
SQLite DatabaseType = "sqlite"
|
||||
MongoDB DatabaseType = "mongodb"
|
||||
)
|
||||
|
||||
// Service represents a service that interacts with multiple databases
|
||||
type Service interface {
|
||||
Health() map[string]map[string]string
|
||||
GetDB(name string) (*sql.DB, error)
|
||||
GetMongoClient(name string) (*mongo.Client, error)
|
||||
GetReadDB(name string) (*sql.DB, error) // For read replicas
|
||||
Close() error
|
||||
ListDBs() []string
|
||||
GetDBType(name string) (DatabaseType, error)
|
||||
// Tambahkan method untuk WebSocket notifications
|
||||
ListenForChanges(ctx context.Context, dbName string, channels []string, callback func(string, string)) error
|
||||
NotifyChange(dbName, channel, payload string) error
|
||||
GetPrimaryDB(name string) (*sql.DB, error) // Helper untuk get primary DB
|
||||
}
|
||||
|
||||
type service struct {
|
||||
sqlDatabases map[string]*sql.DB
|
||||
mongoClients map[string]*mongo.Client
|
||||
readReplicas map[string][]*sql.DB // Read replicas for load balancing
|
||||
configs map[string]config.DatabaseConfig
|
||||
readConfigs map[string][]config.DatabaseConfig
|
||||
mu sync.RWMutex
|
||||
readBalancer map[string]int // Round-robin counter for read replicas
|
||||
listeners map[string]*pq.Listener // Tambahkan untuk tracking listeners
|
||||
listenersMu sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
dbManager *service
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// New creates a new database service with multiple connections
|
||||
func New(cfg *config.Config) Service {
|
||||
once.Do(func() {
|
||||
dbManager = &service{
|
||||
sqlDatabases: make(map[string]*sql.DB),
|
||||
mongoClients: make(map[string]*mongo.Client),
|
||||
readReplicas: make(map[string][]*sql.DB),
|
||||
configs: make(map[string]config.DatabaseConfig),
|
||||
readConfigs: make(map[string][]config.DatabaseConfig),
|
||||
readBalancer: make(map[string]int),
|
||||
listeners: make(map[string]*pq.Listener),
|
||||
}
|
||||
|
||||
log.Println("Initializing database service...") // Log when the initialization starts
|
||||
// log.Printf("Current Goroutine ID: %d", runtime.NumGoroutine()) // Log the number of goroutines
|
||||
// log.Printf("Stack Trace: %s", debug.Stack()) // Log the stack trace
|
||||
dbManager.loadFromConfig(cfg)
|
||||
|
||||
// Initialize all databases
|
||||
for name, dbConfig := range dbManager.configs {
|
||||
if err := dbManager.addDatabase(name, dbConfig); err != nil {
|
||||
log.Printf("Failed to connect to database %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize read replicas
|
||||
for name, replicaConfigs := range dbManager.readConfigs {
|
||||
for i, replicaConfig := range replicaConfigs {
|
||||
if err := dbManager.addReadReplica(name, i, replicaConfig); err != nil {
|
||||
log.Printf("Failed to connect to read replica %s[%d]: %v", name, i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return dbManager
|
||||
}
|
||||
|
||||
func (s *service) loadFromConfig(cfg *config.Config) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Load primary databases
|
||||
for name, dbConfig := range cfg.Databases {
|
||||
s.configs[name] = dbConfig
|
||||
}
|
||||
|
||||
// Load read replicas
|
||||
for name, replicaConfigs := range cfg.ReadReplicas {
|
||||
s.readConfigs[name] = replicaConfigs
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) addDatabase(name string, config config.DatabaseConfig) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
log.Printf("=== Database Connection Debug ===")
|
||||
// log.Printf("Database: %s", name)
|
||||
// log.Printf("Type: %s", config.Type)
|
||||
// log.Printf("Host: %s", config.Host)
|
||||
// log.Printf("Port: %d", config.Port)
|
||||
// log.Printf("Database: %s", config.Database)
|
||||
// log.Printf("Username: %s", config.Username)
|
||||
// log.Printf("SSLMode: %s", config.SSLMode)
|
||||
|
||||
var db *sql.DB
|
||||
var err error
|
||||
|
||||
dbType := DatabaseType(config.Type)
|
||||
|
||||
switch dbType {
|
||||
case Postgres:
|
||||
db, err = s.openPostgresConnection(config)
|
||||
case MySQL:
|
||||
db, err = s.openMySQLConnection(config)
|
||||
case SQLServer:
|
||||
db, err = s.openSQLServerConnection(config)
|
||||
case SQLite:
|
||||
db, err = s.openSQLiteConnection(config)
|
||||
case MongoDB:
|
||||
return s.addMongoDB(name, config)
|
||||
default:
|
||||
return fmt.Errorf("unsupported database type: %s", config.Type)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ Error connecting to database %s: %v", name, err)
|
||||
log.Printf(" Database: %s@%s:%d/%s", config.Username, config.Host, config.Port, config.Database)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("✅ Successfully connected to database: %s", name)
|
||||
return s.configureSQLDB(name, db, config.MaxOpenConns, config.MaxIdleConns, config.ConnMaxLifetime)
|
||||
}
|
||||
|
||||
func (s *service) addReadReplica(name string, index int, config config.DatabaseConfig) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var db *sql.DB
|
||||
var err error
|
||||
|
||||
dbType := DatabaseType(config.Type)
|
||||
|
||||
switch dbType {
|
||||
case Postgres:
|
||||
db, err = s.openPostgresConnection(config)
|
||||
case MySQL:
|
||||
db, err = s.openMySQLConnection(config)
|
||||
case SQLServer:
|
||||
db, err = s.openSQLServerConnection(config)
|
||||
case SQLite:
|
||||
db, err = s.openSQLiteConnection(config)
|
||||
default:
|
||||
return fmt.Errorf("unsupported database type for read replica: %s", config.Type)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.readReplicas[name] == nil {
|
||||
s.readReplicas[name] = make([]*sql.DB, 0)
|
||||
}
|
||||
|
||||
// Ensure we have enough slots
|
||||
for len(s.readReplicas[name]) <= index {
|
||||
s.readReplicas[name] = append(s.readReplicas[name], nil)
|
||||
}
|
||||
|
||||
s.readReplicas[name][index] = db
|
||||
log.Printf("Successfully connected to read replica %s[%d]", name, index)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) openPostgresConnection(config config.DatabaseConfig) (*sql.DB, error) {
|
||||
connStr := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
|
||||
config.Username,
|
||||
config.Password,
|
||||
config.Host,
|
||||
config.Port,
|
||||
config.Database,
|
||||
config.SSLMode,
|
||||
)
|
||||
|
||||
if config.Schema != "" {
|
||||
connStr += "&search_path=" + config.Schema
|
||||
}
|
||||
|
||||
db, err := sql.Open("pgx", connStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open PostgreSQL connection: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (s *service) openMySQLConnection(config config.DatabaseConfig) (*sql.DB, error) {
|
||||
connStr := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true",
|
||||
config.Username,
|
||||
config.Password,
|
||||
config.Host,
|
||||
config.Port,
|
||||
config.Database,
|
||||
)
|
||||
|
||||
db, err := sql.Open("mysql", connStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open MySQL connection: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (s *service) openSQLServerConnection(config config.DatabaseConfig) (*sql.DB, error) {
|
||||
connStr := fmt.Sprintf("sqlserver://%s:%s@%s:%d?database=%s",
|
||||
config.Username,
|
||||
config.Password,
|
||||
config.Host,
|
||||
config.Port,
|
||||
config.Database,
|
||||
)
|
||||
|
||||
db, err := sql.Open("sqlserver", connStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open SQL Server connection: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (s *service) openSQLiteConnection(config config.DatabaseConfig) (*sql.DB, error) {
|
||||
dbPath := config.Path
|
||||
if dbPath == "" {
|
||||
dbPath = fmt.Sprintf("./data/%s.db", config.Database)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open SQLite connection: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (s *service) addMongoDB(name string, config config.DatabaseConfig) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
uri := fmt.Sprintf("mongodb://%s:%s@%s:%d/%s",
|
||||
config.Username,
|
||||
config.Password,
|
||||
config.Host,
|
||||
config.Port,
|
||||
config.Database,
|
||||
)
|
||||
|
||||
client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to MongoDB: %w", err)
|
||||
}
|
||||
|
||||
s.mongoClients[name] = client
|
||||
log.Printf("Successfully connected to MongoDB: %s", name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) configureSQLDB(name string, db *sql.DB, maxOpenConns, maxIdleConns int, connMaxLifetime time.Duration) error {
|
||||
db.SetMaxOpenConns(maxOpenConns)
|
||||
db.SetMaxIdleConns(maxIdleConns)
|
||||
db.SetConnMaxLifetime(connMaxLifetime)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
return fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
s.sqlDatabases[name] = db
|
||||
log.Printf("Successfully connected to SQL database: %s", name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Health checks the health of all database connections by pinging each database.
|
||||
func (s *service) Health() map[string]map[string]string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
result := make(map[string]map[string]string)
|
||||
|
||||
// Check SQL databases
|
||||
for name, db := range s.sqlDatabases {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stats := make(map[string]string)
|
||||
|
||||
err := db.PingContext(ctx)
|
||||
if err != nil {
|
||||
stats["status"] = "down"
|
||||
stats["error"] = fmt.Sprintf("db down: %v", err)
|
||||
stats["type"] = "sql"
|
||||
stats["role"] = "primary"
|
||||
result[name] = stats
|
||||
continue
|
||||
}
|
||||
|
||||
stats["status"] = "up"
|
||||
stats["message"] = "It's healthy"
|
||||
stats["type"] = "sql"
|
||||
stats["role"] = "primary"
|
||||
|
||||
dbStats := db.Stats()
|
||||
stats["open_connections"] = strconv.Itoa(dbStats.OpenConnections)
|
||||
stats["in_use"] = strconv.Itoa(dbStats.InUse)
|
||||
stats["idle"] = strconv.Itoa(dbStats.Idle)
|
||||
stats["wait_count"] = strconv.FormatInt(dbStats.WaitCount, 10)
|
||||
stats["wait_duration"] = dbStats.WaitDuration.String()
|
||||
stats["max_idle_closed"] = strconv.FormatInt(dbStats.MaxIdleClosed, 10)
|
||||
stats["max_lifetime_closed"] = strconv.FormatInt(dbStats.MaxLifetimeClosed, 10)
|
||||
|
||||
if dbStats.OpenConnections > 40 {
|
||||
stats["message"] = "The database is experiencing heavy load."
|
||||
}
|
||||
|
||||
if dbStats.WaitCount > 1000 {
|
||||
stats["message"] = "The database has a high number of wait events, indicating potential bottlenecks."
|
||||
}
|
||||
|
||||
if dbStats.MaxIdleClosed > int64(dbStats.OpenConnections)/2 {
|
||||
stats["message"] = "Many idle connections are being closed, consider revising the connection pool settings."
|
||||
}
|
||||
|
||||
if dbStats.MaxLifetimeClosed > int64(dbStats.OpenConnections)/2 {
|
||||
stats["message"] = "Many connections are being closed due to max lifetime, consider increasing max lifetime or revising the connection usage pattern."
|
||||
}
|
||||
|
||||
result[name] = stats
|
||||
}
|
||||
|
||||
// Check read replicas
|
||||
for name, replicas := range s.readReplicas {
|
||||
for i, db := range replicas {
|
||||
if db == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
replicaName := fmt.Sprintf("%s_replica_%d", name, i)
|
||||
stats := make(map[string]string)
|
||||
|
||||
err := db.PingContext(ctx)
|
||||
if err != nil {
|
||||
stats["status"] = "down"
|
||||
stats["error"] = fmt.Sprintf("read replica down: %v", err)
|
||||
stats["type"] = "sql"
|
||||
stats["role"] = "replica"
|
||||
result[replicaName] = stats
|
||||
continue
|
||||
}
|
||||
|
||||
stats["status"] = "up"
|
||||
stats["message"] = "Read replica healthy"
|
||||
stats["type"] = "sql"
|
||||
stats["role"] = "replica"
|
||||
|
||||
dbStats := db.Stats()
|
||||
stats["open_connections"] = strconv.Itoa(dbStats.OpenConnections)
|
||||
stats["in_use"] = strconv.Itoa(dbStats.InUse)
|
||||
stats["idle"] = strconv.Itoa(dbStats.Idle)
|
||||
|
||||
result[replicaName] = stats
|
||||
}
|
||||
}
|
||||
|
||||
// Check MongoDB connections
|
||||
for name, client := range s.mongoClients {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stats := make(map[string]string)
|
||||
|
||||
err := client.Ping(ctx, nil)
|
||||
if err != nil {
|
||||
stats["status"] = "down"
|
||||
stats["error"] = fmt.Sprintf("mongodb down: %v", err)
|
||||
stats["type"] = "mongodb"
|
||||
result[name] = stats
|
||||
continue
|
||||
}
|
||||
|
||||
stats["status"] = "up"
|
||||
stats["message"] = "It's healthy"
|
||||
stats["type"] = "mongodb"
|
||||
|
||||
result[name] = stats
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetDB returns a specific SQL database connection by name
|
||||
func (s *service) GetDB(name string) (*sql.DB, error) {
|
||||
log.Printf("Attempting to get database connection for: %s", name)
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
db, exists := s.sqlDatabases[name]
|
||||
if !exists {
|
||||
log.Printf("Error: database %s not found", name) // Log the error
|
||||
return nil, fmt.Errorf("database %s not found", name)
|
||||
}
|
||||
|
||||
log.Printf("Current connection pool state for %s: Open: %d, In Use: %d, Idle: %d",
|
||||
name, db.Stats().OpenConnections, db.Stats().InUse, db.Stats().Idle)
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// db, exists := s.sqlDatabases[name]
|
||||
// if !exists {
|
||||
// log.Printf("Error: database %s not found", name) // Log the error
|
||||
// return nil, fmt.Errorf("database %s not found", name)
|
||||
// }
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// GetReadDB returns a read replica connection using round-robin load balancing
|
||||
func (s *service) GetReadDB(name string) (*sql.DB, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
replicas, exists := s.readReplicas[name]
|
||||
if !exists || len(replicas) == 0 {
|
||||
// Fallback to primary if no replicas available
|
||||
return s.GetDB(name)
|
||||
}
|
||||
|
||||
// Round-robin load balancing
|
||||
s.readBalancer[name] = (s.readBalancer[name] + 1) % len(replicas)
|
||||
selected := replicas[s.readBalancer[name]]
|
||||
|
||||
if selected == nil {
|
||||
// Fallback to primary if replica is nil
|
||||
return s.GetDB(name)
|
||||
}
|
||||
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
// GetMongoClient returns a specific MongoDB client by name
|
||||
func (s *service) GetMongoClient(name string) (*mongo.Client, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
client, exists := s.mongoClients[name]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("MongoDB client %s not found", name)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// ListDBs returns list of available database names
|
||||
func (s *service) ListDBs() []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
names := make([]string, 0, len(s.sqlDatabases)+len(s.mongoClients))
|
||||
|
||||
for name := range s.sqlDatabases {
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
for name := range s.mongoClients {
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
// GetDBType returns the type of a specific database
|
||||
func (s *service) GetDBType(name string) (DatabaseType, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
config, exists := s.configs[name]
|
||||
if !exists {
|
||||
return "", fmt.Errorf("database %s not found", name)
|
||||
}
|
||||
|
||||
return DatabaseType(config.Type), nil
|
||||
}
|
||||
|
||||
// Close closes all database connections
|
||||
func (s *service) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var errs []error
|
||||
|
||||
for name, db := range s.sqlDatabases {
|
||||
if err := db.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to close database %s: %w", name, err))
|
||||
} else {
|
||||
log.Printf("Disconnected from SQL database: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
for name, replicas := range s.readReplicas {
|
||||
for i, db := range replicas {
|
||||
if db != nil {
|
||||
if err := db.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to close read replica %s[%d]: %w", name, i, err))
|
||||
} else {
|
||||
log.Printf("Disconnected from read replica: %s[%d]", name, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for name, client := range s.mongoClients {
|
||||
if err := client.Disconnect(context.Background()); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to disconnect MongoDB client %s: %w", name, err))
|
||||
} else {
|
||||
log.Printf("Disconnected from MongoDB: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
s.sqlDatabases = make(map[string]*sql.DB)
|
||||
s.mongoClients = make(map[string]*mongo.Client)
|
||||
s.readReplicas = make(map[string][]*sql.DB)
|
||||
s.configs = make(map[string]config.DatabaseConfig)
|
||||
s.readConfigs = make(map[string][]config.DatabaseConfig)
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("errors closing databases: %v", errs)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPrimaryDB returns primary database connection
|
||||
func (s *service) GetPrimaryDB(name string) (*sql.DB, error) {
|
||||
return s.GetDB(name)
|
||||
}
|
||||
|
||||
// ListenForChanges implements PostgreSQL LISTEN/NOTIFY for real-time updates
|
||||
func (s *service) ListenForChanges(ctx context.Context, dbName string, channels []string, callback func(string, string)) error {
|
||||
s.mu.RLock()
|
||||
config, exists := s.configs[dbName]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("database %s not found", dbName)
|
||||
}
|
||||
|
||||
// Only support PostgreSQL for LISTEN/NOTIFY
|
||||
if DatabaseType(config.Type) != Postgres {
|
||||
return fmt.Errorf("LISTEN/NOTIFY only supported for PostgreSQL databases")
|
||||
}
|
||||
|
||||
// Create connection string for listener
|
||||
connStr := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
|
||||
config.Username,
|
||||
config.Password,
|
||||
config.Host,
|
||||
config.Port,
|
||||
config.Database,
|
||||
config.SSLMode,
|
||||
)
|
||||
|
||||
// Create listener
|
||||
listener := pq.NewListener(
|
||||
connStr,
|
||||
10*time.Second,
|
||||
time.Minute,
|
||||
func(ev pq.ListenerEventType, err error) {
|
||||
if err != nil {
|
||||
log.Printf("Database listener (%s) error: %v", dbName, err)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Store listener for cleanup
|
||||
s.listenersMu.Lock()
|
||||
s.listeners[dbName] = listener
|
||||
s.listenersMu.Unlock()
|
||||
|
||||
// Listen to specified channels
|
||||
for _, channel := range channels {
|
||||
err := listener.Listen(channel)
|
||||
if err != nil {
|
||||
listener.Close()
|
||||
return fmt.Errorf("failed to listen to channel %s: %w", channel, err)
|
||||
}
|
||||
log.Printf("Listening to database channel: %s on %s", channel, dbName)
|
||||
}
|
||||
|
||||
// Start listening loop
|
||||
go func() {
|
||||
defer func() {
|
||||
listener.Close()
|
||||
s.listenersMu.Lock()
|
||||
delete(s.listeners, dbName)
|
||||
s.listenersMu.Unlock()
|
||||
log.Printf("Database listener for %s stopped", dbName)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case n := <-listener.Notify:
|
||||
if n != nil {
|
||||
callback(n.Channel, n.Extra)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(90 * time.Second):
|
||||
// Send ping to keep connection alive
|
||||
go func() {
|
||||
if err := listener.Ping(); err != nil {
|
||||
log.Printf("Listener ping failed for %s: %v", dbName, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotifyChange sends a notification to a PostgreSQL channel
|
||||
func (s *service) NotifyChange(dbName, channel, payload string) error {
|
||||
db, err := s.GetDB(dbName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get database %s: %w", dbName, err)
|
||||
}
|
||||
|
||||
// Check if it's PostgreSQL
|
||||
s.mu.RLock()
|
||||
config, exists := s.configs[dbName]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("database %s configuration not found", dbName)
|
||||
}
|
||||
|
||||
if DatabaseType(config.Type) != Postgres {
|
||||
return fmt.Errorf("NOTIFY only supported for PostgreSQL databases")
|
||||
}
|
||||
|
||||
// Execute NOTIFY
|
||||
query := "SELECT pg_notify($1, $2)"
|
||||
_, err = db.Exec(query, channel, payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send notification: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Sent notification to channel %s on %s: %s", channel, dbName, payload)
|
||||
return nil
|
||||
}
|
||||
132
internal/handlers/auth/auth.go
Normal file
132
internal/handlers/auth/auth.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
models "api-service/internal/models/auth"
|
||||
services "api-service/internal/services/auth"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuthHandler handles authentication endpoints
|
||||
type AuthHandler struct {
|
||||
authService *services.AuthService
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new authentication handler
|
||||
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
// Login godoc
|
||||
// @Summary Login user and get JWT token
|
||||
// @Description Authenticate user with username and password to receive JWT token
|
||||
// @Tags Authentication
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param login body models.LoginRequest true "Login credentials"
|
||||
// @Success 200 {object} models.TokenResponse
|
||||
// @Failure 400 {object} map[string]string "Bad request"
|
||||
// @Failure 401 {object} map[string]string "Unauthorized"
|
||||
// @Router /api/v1/auth/login [post]
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var loginReq models.LoginRequest
|
||||
|
||||
// Bind JSON request
|
||||
if err := c.ShouldBindJSON(&loginReq); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
tokenResponse, err := h.authService.Login(loginReq.Username, loginReq.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tokenResponse)
|
||||
}
|
||||
|
||||
// RefreshToken godoc
|
||||
// @Summary Refresh JWT token
|
||||
// @Description Refresh the JWT token using a valid refresh token
|
||||
// @Tags Authentication
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param refresh body map[string]string true "Refresh token"
|
||||
// @Success 200 {object} models.TokenResponse
|
||||
// @Failure 400 {object} map[string]string "Bad request"
|
||||
// @Failure 401 {object} map[string]string "Unauthorized"
|
||||
// @Router /api/v1/auth/refresh [post]
|
||||
func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||||
// For now, this is a placeholder for refresh token functionality
|
||||
// In a real implementation, you would handle refresh tokens here
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "refresh token not implemented"})
|
||||
}
|
||||
|
||||
// Register godoc
|
||||
// @Summary Register new user
|
||||
// @Description Register a new user account
|
||||
// @Tags Authentication
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param register body map[string]string true "Registration data"
|
||||
// @Success 201 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string "Bad request"
|
||||
// @Router /api/v1/auth/register [post]
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var registerReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Role string `json:"role" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(®isterReq); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.authService.RegisterUser(
|
||||
registerReq.Username,
|
||||
registerReq.Email,
|
||||
registerReq.Password,
|
||||
registerReq.Role,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"message": "user registered successfully"})
|
||||
}
|
||||
|
||||
// Me godoc
|
||||
// @Summary Get current user info
|
||||
// @Description Get information about the currently authenticated user
|
||||
// @Tags Authentication
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Success 200 {object} models.User
|
||||
// @Failure 401 {object} map[string]string "Unauthorized"
|
||||
// @Router /api/v1/auth/me [get]
|
||||
func (h *AuthHandler) Me(c *gin.Context) {
|
||||
// Get user info from context (set by middleware)
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
// In a real implementation, you would fetch user details from database
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": userID,
|
||||
"username": c.GetString("username"),
|
||||
"email": c.GetString("email"),
|
||||
"role": c.GetString("role"),
|
||||
})
|
||||
}
|
||||
95
internal/handlers/auth/token.go
Normal file
95
internal/handlers/auth/token.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
models "api-service/internal/models/auth"
|
||||
services "api-service/internal/services/auth"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TokenHandler handles token generation endpoints
|
||||
type TokenHandler struct {
|
||||
authService *services.AuthService
|
||||
}
|
||||
|
||||
// NewTokenHandler creates a new token handler
|
||||
func NewTokenHandler(authService *services.AuthService) *TokenHandler {
|
||||
return &TokenHandler{
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateToken godoc
|
||||
// @Summary Generate JWT token
|
||||
// @Description Generate a JWT token for a user
|
||||
// @Tags Token
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param token body models.LoginRequest true "User credentials"
|
||||
// @Success 200 {object} models.TokenResponse
|
||||
// @Failure 400 {object} map[string]string "Bad request"
|
||||
// @Failure 401 {object} map[string]string "Unauthorized"
|
||||
// @Router /api/v1/token/generate [post]
|
||||
func (h *TokenHandler) GenerateToken(c *gin.Context) {
|
||||
var loginReq models.LoginRequest
|
||||
|
||||
// Bind JSON request
|
||||
if err := c.ShouldBindJSON(&loginReq); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate token
|
||||
tokenResponse, err := h.authService.Login(loginReq.Username, loginReq.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tokenResponse)
|
||||
}
|
||||
|
||||
// GenerateTokenDirect godoc
|
||||
// @Summary Generate token directly
|
||||
// @Description Generate a JWT token directly without password verification (for testing)
|
||||
// @Tags Token
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param user body map[string]string true "User info"
|
||||
// @Success 200 {object} models.TokenResponse
|
||||
// @Failure 400 {object} map[string]string "Bad request"
|
||||
// @Router /api/v1/token/generate-direct [post]
|
||||
func (h *TokenHandler) GenerateTokenDirect(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Email string `json:"email" binding:"required"`
|
||||
Role string `json:"role" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Create a temporary user for token generation
|
||||
user := &models.User{
|
||||
ID: "temp-" + req.Username,
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Role: req.Role,
|
||||
}
|
||||
|
||||
// Generate token directly
|
||||
token, err := h.authService.GenerateTokenForUser(user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.TokenResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
})
|
||||
}
|
||||
24
internal/handlers/healthcheck/healthcheck.go
Normal file
24
internal/handlers/healthcheck/healthcheck.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package healthcheck
|
||||
|
||||
import (
|
||||
"api-service/internal/database"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HealthCheckHandler handles health check requests
|
||||
type HealthCheckHandler struct {
|
||||
dbService database.Service
|
||||
}
|
||||
|
||||
// NewHealthCheckHandler creates a new HealthCheckHandler
|
||||
func NewHealthCheckHandler(dbService database.Service) *HealthCheckHandler {
|
||||
return &HealthCheckHandler{dbService: dbService}
|
||||
}
|
||||
|
||||
// CheckHealth checks the health of the application
|
||||
func (h *HealthCheckHandler) CheckHealth(c *gin.Context) {
|
||||
healthStatus := h.dbService.Health() // Call the health check function from the database service
|
||||
c.JSON(http.StatusOK, healthStatus)
|
||||
}
|
||||
1401
internal/handlers/retribusi/retribusi.go
Normal file
1401
internal/handlers/retribusi/retribusi.go
Normal file
File diff suppressed because it is too large
Load Diff
111
internal/handlers/websocket/broadcast.go
Normal file
111
internal/handlers/websocket/broadcast.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WebSocketBroadcaster defines the interface for broadcasting messages
|
||||
type WebSocketBroadcaster interface {
|
||||
BroadcastMessage(messageType string, data interface{})
|
||||
}
|
||||
|
||||
// Broadcaster handles server-initiated broadcasts to WebSocket clients
|
||||
type Broadcaster struct {
|
||||
handler WebSocketBroadcaster
|
||||
tickers []*time.Ticker
|
||||
quit chan struct{}
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewBroadcaster creates a new Broadcaster instance
|
||||
func NewBroadcaster(handler WebSocketBroadcaster) *Broadcaster {
|
||||
return &Broadcaster{
|
||||
handler: handler,
|
||||
tickers: make([]*time.Ticker, 0),
|
||||
quit: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// StartHeartbeat starts sending periodic heartbeat messages to all clients
|
||||
func (b *Broadcaster) StartHeartbeat(interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
b.tickers = append(b.tickers, ticker)
|
||||
go func() {
|
||||
defer func() {
|
||||
// Remove ticker from slice when done
|
||||
for i, t := range b.tickers {
|
||||
if t == ticker {
|
||||
b.tickers = append(b.tickers[:i], b.tickers[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
b.handler.BroadcastMessage("heartbeat", map[string]interface{}{
|
||||
"message": "Server heartbeat",
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
case <-b.quit:
|
||||
ticker.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop stops the broadcaster
|
||||
func (b *Broadcaster) Stop() {
|
||||
close(b.quit)
|
||||
for _, ticker := range b.tickers {
|
||||
if ticker != nil {
|
||||
ticker.Stop()
|
||||
}
|
||||
}
|
||||
b.tickers = nil
|
||||
}
|
||||
|
||||
// BroadcastNotification sends a notification message to all clients
|
||||
func (b *Broadcaster) BroadcastNotification(title, message, level string) {
|
||||
b.handler.BroadcastMessage("notification", map[string]interface{}{
|
||||
"title": title,
|
||||
"message": message,
|
||||
"level": level,
|
||||
"time": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// SimulateDataStream simulates streaming data to clients (useful for demos)
|
||||
func (b *Broadcaster) SimulateDataStream() {
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
b.tickers = append(b.tickers, ticker)
|
||||
go func() {
|
||||
defer func() {
|
||||
// Remove ticker from slice when done
|
||||
for i, t := range b.tickers {
|
||||
if t == ticker {
|
||||
b.tickers = append(b.tickers[:i], b.tickers[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
counter := 0
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
counter++
|
||||
b.handler.BroadcastMessage("data_stream", map[string]interface{}{
|
||||
"id": counter,
|
||||
"value": counter * 10,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
"type": "simulated_data",
|
||||
})
|
||||
case <-b.quit:
|
||||
ticker.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
251
internal/handlers/websocket/broadcast_test.go
Normal file
251
internal/handlers/websocket/broadcast_test.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MockWebSocketHandler is a mock implementation for testing
|
||||
type MockWebSocketHandler struct {
|
||||
mu sync.Mutex
|
||||
messages []map[string]interface{}
|
||||
broadcasts []string
|
||||
}
|
||||
|
||||
func (m *MockWebSocketHandler) BroadcastMessage(messageType string, data interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.broadcasts = append(m.broadcasts, messageType)
|
||||
m.messages = append(m.messages, map[string]interface{}{
|
||||
"type": messageType,
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *MockWebSocketHandler) GetMessages() []map[string]interface{} {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
result := make([]map[string]interface{}, len(m.messages))
|
||||
copy(result, m.messages)
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *MockWebSocketHandler) GetBroadcasts() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
result := make([]string, len(m.broadcasts))
|
||||
copy(result, m.broadcasts)
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *MockWebSocketHandler) Clear() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.messages = make([]map[string]interface{}, 0)
|
||||
m.broadcasts = make([]string, 0)
|
||||
}
|
||||
|
||||
func NewMockWebSocketHandler() *MockWebSocketHandler {
|
||||
return &MockWebSocketHandler{
|
||||
messages: make([]map[string]interface{}, 0),
|
||||
broadcasts: make([]string, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcaster_StartHeartbeat(t *testing.T) {
|
||||
mockHandler := NewMockWebSocketHandler()
|
||||
broadcaster := NewBroadcaster(mockHandler)
|
||||
|
||||
// Start heartbeat with short interval for testing
|
||||
broadcaster.StartHeartbeat(100 * time.Millisecond)
|
||||
|
||||
// Wait for a few heartbeats
|
||||
time.Sleep(350 * time.Millisecond)
|
||||
|
||||
// Stop the broadcaster
|
||||
broadcaster.Stop()
|
||||
|
||||
// Check if heartbeats were sent
|
||||
messages := mockHandler.GetMessages()
|
||||
if len(messages) == 0 {
|
||||
t.Error("Expected heartbeat messages, but got none")
|
||||
}
|
||||
|
||||
// Check that all messages are heartbeat type
|
||||
broadcasts := mockHandler.GetBroadcasts()
|
||||
for _, msgType := range broadcasts {
|
||||
if msgType != "heartbeat" {
|
||||
t.Errorf("Expected heartbeat message type, got %s", msgType)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Received %d heartbeat messages", len(messages))
|
||||
}
|
||||
|
||||
func TestBroadcaster_BroadcastNotification(t *testing.T) {
|
||||
mockHandler := NewMockWebSocketHandler()
|
||||
broadcaster := NewBroadcaster(mockHandler)
|
||||
|
||||
// Send a notification
|
||||
broadcaster.BroadcastNotification("Test Title", "Test Message", "info")
|
||||
|
||||
// Check if notification was sent
|
||||
messages := mockHandler.GetMessages()
|
||||
if len(messages) != 1 {
|
||||
t.Errorf("Expected 1 message, got %d", len(messages))
|
||||
return
|
||||
}
|
||||
|
||||
msg := messages[0]
|
||||
if msg["type"] != "notification" {
|
||||
t.Errorf("Expected message type 'notification', got %s", msg["type"])
|
||||
}
|
||||
|
||||
data := msg["data"].(map[string]interface{})
|
||||
if data["title"] != "Test Title" {
|
||||
t.Errorf("Expected title 'Test Title', got %s", data["title"])
|
||||
}
|
||||
if data["message"] != "Test Message" {
|
||||
t.Errorf("Expected message 'Test Message', got %s", data["message"])
|
||||
}
|
||||
if data["level"] != "info" {
|
||||
t.Errorf("Expected level 'info', got %s", data["level"])
|
||||
}
|
||||
|
||||
t.Logf("Notification sent successfully: %+v", data)
|
||||
}
|
||||
|
||||
func TestBroadcaster_SimulateDataStream(t *testing.T) {
|
||||
mockHandler := NewMockWebSocketHandler()
|
||||
broadcaster := NewBroadcaster(mockHandler)
|
||||
|
||||
// Start data stream with short interval for testing
|
||||
broadcaster.SimulateDataStream()
|
||||
|
||||
// Wait for a few data points
|
||||
time.Sleep(550 * time.Millisecond)
|
||||
|
||||
// Stop the broadcaster
|
||||
broadcaster.Stop()
|
||||
|
||||
// Check if data stream messages were sent
|
||||
messages := mockHandler.GetMessages()
|
||||
if len(messages) == 0 {
|
||||
t.Error("Expected data stream messages, but got none")
|
||||
}
|
||||
|
||||
// Check that all messages are data_stream type
|
||||
broadcasts := mockHandler.GetBroadcasts()
|
||||
for _, msgType := range broadcasts {
|
||||
if msgType != "data_stream" {
|
||||
t.Errorf("Expected data_stream message type, got %s", msgType)
|
||||
}
|
||||
}
|
||||
|
||||
// Check data structure
|
||||
for i, msg := range messages {
|
||||
data := msg["data"].(map[string]interface{})
|
||||
if data["type"] != "simulated_data" {
|
||||
t.Errorf("Expected data type 'simulated_data', got %s", data["type"])
|
||||
}
|
||||
if id, ok := data["id"].(int); ok {
|
||||
if id != i+1 {
|
||||
t.Errorf("Expected id %d, got %d", i+1, id)
|
||||
}
|
||||
}
|
||||
if value, ok := data["value"].(int); ok {
|
||||
expectedValue := (i + 1) * 10
|
||||
if value != expectedValue {
|
||||
t.Errorf("Expected value %d, got %d", expectedValue, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Received %d data stream messages", len(messages))
|
||||
}
|
||||
|
||||
func TestBroadcaster_Stop(t *testing.T) {
|
||||
mockHandler := NewMockWebSocketHandler()
|
||||
broadcaster := NewBroadcaster(mockHandler)
|
||||
|
||||
// Start heartbeat
|
||||
broadcaster.StartHeartbeat(50 * time.Millisecond)
|
||||
|
||||
// Wait a bit
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Stop the broadcaster
|
||||
broadcaster.Stop()
|
||||
|
||||
// Clear previous messages
|
||||
mockHandler.Clear()
|
||||
|
||||
// Wait a bit more to ensure no new messages are sent
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Check that no new messages were sent after stopping
|
||||
messages := mockHandler.GetMessages()
|
||||
if len(messages) > 0 {
|
||||
t.Errorf("Expected no messages after stopping, but got %d", len(messages))
|
||||
}
|
||||
|
||||
// Clear quit channel to allow reuse in tests
|
||||
broadcaster.quit = make(chan struct{})
|
||||
|
||||
t.Log("Broadcaster stopped successfully")
|
||||
}
|
||||
|
||||
func TestBroadcaster_MultipleOperations(t *testing.T) {
|
||||
mockHandler := NewMockWebSocketHandler()
|
||||
broadcaster := NewBroadcaster(mockHandler)
|
||||
|
||||
// Start heartbeat
|
||||
broadcaster.StartHeartbeat(100 * time.Millisecond)
|
||||
|
||||
// Send notification
|
||||
broadcaster.BroadcastNotification("Test", "Message", "warning")
|
||||
|
||||
// Start data stream
|
||||
broadcaster.SimulateDataStream()
|
||||
|
||||
// Wait for some activity
|
||||
time.Sleep(350 * time.Millisecond)
|
||||
|
||||
// Stop everything
|
||||
broadcaster.Stop()
|
||||
|
||||
// Check results
|
||||
messages := mockHandler.GetMessages()
|
||||
if len(messages) == 0 {
|
||||
t.Error("Expected messages from multiple operations, but got none")
|
||||
}
|
||||
|
||||
broadcasts := mockHandler.GetBroadcasts()
|
||||
hasHeartbeat := false
|
||||
hasNotification := false
|
||||
hasDataStream := false
|
||||
|
||||
for _, msgType := range broadcasts {
|
||||
switch msgType {
|
||||
case "heartbeat":
|
||||
hasHeartbeat = true
|
||||
case "notification":
|
||||
hasNotification = true
|
||||
case "data_stream":
|
||||
hasDataStream = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasHeartbeat {
|
||||
t.Error("Expected heartbeat messages")
|
||||
}
|
||||
if !hasNotification {
|
||||
t.Error("Expected notification message")
|
||||
}
|
||||
if !hasDataStream {
|
||||
t.Error("Expected data stream messages")
|
||||
}
|
||||
|
||||
t.Logf("Multiple operations test passed: %d total messages", len(messages))
|
||||
}
|
||||
1621
internal/handlers/websocket/websocket.go
Normal file
1621
internal/handlers/websocket/websocket.go
Normal file
File diff suppressed because it is too large
Load Diff
59
internal/middleware/auth_middleware.go
Normal file
59
internal/middleware/auth_middleware.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"api-service/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ConfigurableAuthMiddleware provides flexible authentication based on configuration
|
||||
func ConfigurableAuthMiddleware(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Skip authentication for development/testing if explicitly disabled
|
||||
if !cfg.Keycloak.Enabled {
|
||||
fmt.Println("Authentication is disabled - allowing all requests")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Use Keycloak authentication when enabled
|
||||
AuthMiddleware()(c)
|
||||
}
|
||||
}
|
||||
|
||||
// StrictAuthMiddleware enforces authentication regardless of Keycloak.Enabled setting
|
||||
func StrictAuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if appConfig == nil {
|
||||
fmt.Println("AuthMiddleware: Config not initialized")
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "authentication service not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
// Always enforce authentication
|
||||
AuthMiddleware()(c)
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalKeycloakAuthMiddleware allows requests but adds authentication info if available
|
||||
func OptionalKeycloakAuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if appConfig == nil || !appConfig.Keycloak.Enabled {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
// No token provided, but continue
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Try to validate token, but don't fail if invalid
|
||||
AuthMiddleware()(c)
|
||||
}
|
||||
}
|
||||
54
internal/middleware/error_handler.go
Normal file
54
internal/middleware/error_handler.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
models "api-service/internal/models"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ErrorHandler handles errors globally
|
||||
func ErrorHandler() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
|
||||
if len(c.Errors) > 0 {
|
||||
err := c.Errors.Last()
|
||||
status := http.StatusInternalServerError
|
||||
|
||||
// Determine status code based on error type
|
||||
switch err.Type {
|
||||
case gin.ErrorTypeBind:
|
||||
status = http.StatusBadRequest
|
||||
case gin.ErrorTypeRender:
|
||||
status = http.StatusUnprocessableEntity
|
||||
case gin.ErrorTypePrivate:
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
|
||||
response := models.ErrorResponse{
|
||||
Error: "internal_error",
|
||||
Message: err.Error(),
|
||||
Code: status,
|
||||
}
|
||||
|
||||
c.JSON(status, response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CORS middleware configuration
|
||||
func CORSConfig() gin.HandlerFunc {
|
||||
return gin.HandlerFunc(func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
})
|
||||
}
|
||||
77
internal/middleware/jwt_middleware.go
Normal file
77
internal/middleware/jwt_middleware.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
services "api-service/internal/services/auth"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// JWTAuthMiddleware validates JWT tokens generated by our auth service
|
||||
func JWTAuthMiddleware(authService *services.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header missing"})
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := parts[1]
|
||||
|
||||
// Validate token
|
||||
claims, err := authService.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set user info in context
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("email", claims.Email)
|
||||
c.Set("role", claims.Role)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalAuthMiddleware allows both authenticated and unauthenticated requests
|
||||
func OptionalAuthMiddleware(authService *services.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
// No token provided, but continue
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := parts[1]
|
||||
claims, err := authService.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
// Invalid token, but continue (don't abort)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Set user info in context
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("email", claims.Email)
|
||||
c.Set("role", claims.Role)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
254
internal/middleware/keycloak_middleware.go
Normal file
254
internal/middleware/keycloak_middleware.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package middleware
|
||||
|
||||
/** Keycloak Auth Middleware **/
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"api-service/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
)
|
||||
|
||||
// JwksCache caches JWKS keys with expiration
|
||||
type JwksCache struct {
|
||||
mu sync.RWMutex
|
||||
keys map[string]*rsa.PublicKey
|
||||
expiresAt time.Time
|
||||
sfGroup singleflight.Group
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewJwksCache(cfg *config.Config) *JwksCache {
|
||||
return &JwksCache{
|
||||
keys: make(map[string]*rsa.PublicKey),
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *JwksCache) GetKey(kid string) (*rsa.PublicKey, error) {
|
||||
c.mu.RLock()
|
||||
if key, ok := c.keys[kid]; ok && time.Now().Before(c.expiresAt) {
|
||||
c.mu.RUnlock()
|
||||
return key, nil
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
// Fetch keys with singleflight to avoid concurrent fetches
|
||||
v, err, _ := c.sfGroup.Do("fetch_jwks", func() (interface{}, error) {
|
||||
return c.fetchKeys()
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keys := v.(map[string]*rsa.PublicKey)
|
||||
|
||||
c.mu.Lock()
|
||||
c.keys = keys
|
||||
c.expiresAt = time.Now().Add(1 * time.Hour) // cache for 1 hour
|
||||
c.mu.Unlock()
|
||||
|
||||
key, ok := keys[kid]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("key with kid %s not found", kid)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (c *JwksCache) fetchKeys() (map[string]*rsa.PublicKey, error) {
|
||||
if !c.config.Keycloak.Enabled {
|
||||
return nil, fmt.Errorf("keycloak authentication is disabled")
|
||||
}
|
||||
|
||||
jwksURL := c.config.Keycloak.JwksURL
|
||||
if jwksURL == "" {
|
||||
// Construct JWKS URL from issuer if not explicitly provided
|
||||
jwksURL = c.config.Keycloak.Issuer + "/protocol/openid-connect/certs"
|
||||
}
|
||||
|
||||
resp, err := http.Get(jwksURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var jwksData struct {
|
||||
Keys []struct {
|
||||
Kid string `json:"kid"`
|
||||
Kty string `json:"kty"`
|
||||
N string `json:"n"`
|
||||
E string `json:"e"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&jwksData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keys := make(map[string]*rsa.PublicKey)
|
||||
for _, key := range jwksData.Keys {
|
||||
if key.Kty != "RSA" {
|
||||
continue
|
||||
}
|
||||
pubKey, err := parseRSAPublicKey(key.N, key.E)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
keys[key.Kid] = pubKey
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// parseRSAPublicKey parses RSA public key components from base64url strings
|
||||
func parseRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) {
|
||||
nBytes, err := base64UrlDecode(nStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
eBytes, err := base64UrlDecode(eStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var eInt int
|
||||
for _, b := range eBytes {
|
||||
eInt = eInt<<8 + int(b)
|
||||
}
|
||||
|
||||
pubKey := &rsa.PublicKey{
|
||||
N: new(big.Int).SetBytes(nBytes),
|
||||
E: eInt,
|
||||
}
|
||||
return pubKey, nil
|
||||
}
|
||||
|
||||
func base64UrlDecode(s string) ([]byte, error) {
|
||||
// Add padding if missing
|
||||
if m := len(s) % 4; m != 0 {
|
||||
s += strings.Repeat("=", 4-m)
|
||||
}
|
||||
return base64.URLEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
// Global config instance
|
||||
var appConfig *config.Config
|
||||
var jwksCacheInstance *JwksCache
|
||||
|
||||
// InitializeAuth initializes the auth middleware with config
|
||||
func InitializeAuth(cfg *config.Config) {
|
||||
appConfig = cfg
|
||||
jwksCacheInstance = NewJwksCache(cfg)
|
||||
}
|
||||
|
||||
// AuthMiddleware validates Bearer token as Keycloak JWT token
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if appConfig == nil {
|
||||
fmt.Println("AuthMiddleware: Config not initialized")
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "authentication service not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
if !appConfig.Keycloak.Enabled {
|
||||
// Skip authentication if Keycloak is disabled but log for debugging
|
||||
fmt.Println("AuthMiddleware: Keycloak authentication is disabled - allowing all requests")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("AuthMiddleware: Checking Authorization header") // Debug log
|
||||
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
fmt.Println("AuthMiddleware: Authorization header missing") // Debug log
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header missing"})
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
||||
fmt.Println("AuthMiddleware: Invalid Authorization header format") // Debug log
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := parts[1]
|
||||
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
// Verify signing method
|
||||
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
fmt.Printf("AuthMiddleware: Unexpected signing method: %v\n", token.Header["alg"]) // Debug log
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
kid, ok := token.Header["kid"].(string)
|
||||
if !ok {
|
||||
fmt.Println("AuthMiddleware: kid header not found") // Debug log
|
||||
return nil, errors.New("kid header not found")
|
||||
}
|
||||
|
||||
return jwksCacheInstance.GetKey(kid)
|
||||
}, jwt.WithIssuer(appConfig.Keycloak.Issuer), jwt.WithAudience(appConfig.Keycloak.Audience))
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
fmt.Printf("AuthMiddleware: Invalid or expired token: %v\n", err) // Debug log
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("AuthMiddleware: Token valid, proceeding") // Debug log
|
||||
// Token is valid, proceed
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
/** JWT Bearer authentication middleware */
|
||||
// import (
|
||||
// "net/http"
|
||||
// "strings"
|
||||
|
||||
// "github.com/gin-gonic/gin"
|
||||
// )
|
||||
|
||||
// AuthMiddleware validates Bearer token in Authorization header
|
||||
func AuthJWTMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header missing"})
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
|
||||
return
|
||||
}
|
||||
|
||||
token := parts[1]
|
||||
// For now, use a static token for validation. Replace with your logic.
|
||||
const validToken = "your-static-token"
|
||||
|
||||
if token != validToken {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
31
internal/models/auth/auth.go
Normal file
31
internal/models/auth/auth.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package models
|
||||
|
||||
// LoginRequest represents the login request payload
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// TokenResponse represents the token response
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
|
||||
// JWTClaims represents the JWT claims
|
||||
type JWTClaims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// User represents a user for authentication
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"-"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
221
internal/models/models.go
Normal file
221
internal/models/models.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NullableInt32 - your existing implementation
|
||||
type NullableInt32 struct {
|
||||
Int32 int32 `json:"int32,omitempty"`
|
||||
Valid bool `json:"valid"`
|
||||
}
|
||||
|
||||
// Scan implements the sql.Scanner interface for NullableInt32
|
||||
func (n *NullableInt32) Scan(value interface{}) error {
|
||||
var ni sql.NullInt32
|
||||
if err := ni.Scan(value); err != nil {
|
||||
return err
|
||||
}
|
||||
n.Int32 = ni.Int32
|
||||
n.Valid = ni.Valid
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value implements the driver.Valuer interface for NullableInt32
|
||||
func (n NullableInt32) Value() (driver.Value, error) {
|
||||
if !n.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return n.Int32, nil
|
||||
}
|
||||
|
||||
// NullableString provides consistent nullable string handling
|
||||
type NullableString struct {
|
||||
String string `json:"string,omitempty"`
|
||||
Valid bool `json:"valid"`
|
||||
}
|
||||
|
||||
// Scan implements the sql.Scanner interface for NullableString
|
||||
func (n *NullableString) Scan(value interface{}) error {
|
||||
var ns sql.NullString
|
||||
if err := ns.Scan(value); err != nil {
|
||||
return err
|
||||
}
|
||||
n.String = ns.String
|
||||
n.Valid = ns.Valid
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value implements the driver.Valuer interface for NullableString
|
||||
func (n NullableString) Value() (driver.Value, error) {
|
||||
if !n.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return n.String, nil
|
||||
}
|
||||
|
||||
// NullableTime provides consistent nullable time handling
|
||||
type NullableTime struct {
|
||||
Time time.Time `json:"time,omitempty"`
|
||||
Valid bool `json:"valid"`
|
||||
}
|
||||
|
||||
// Scan implements the sql.Scanner interface for NullableTime
|
||||
func (n *NullableTime) Scan(value interface{}) error {
|
||||
var nt sql.NullTime
|
||||
if err := nt.Scan(value); err != nil {
|
||||
return err
|
||||
}
|
||||
n.Time = nt.Time
|
||||
n.Valid = nt.Valid
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value implements the driver.Valuer interface for NullableTime
|
||||
func (n NullableTime) Value() (driver.Value, error) {
|
||||
if !n.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return n.Time, nil
|
||||
}
|
||||
|
||||
// Metadata untuk pagination - dioptimalkan
|
||||
type MetaResponse struct {
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Total int `json:"total"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
CurrentPage int `json:"current_page"`
|
||||
HasNext bool `json:"has_next"`
|
||||
HasPrev bool `json:"has_prev"`
|
||||
}
|
||||
|
||||
// Aggregate data untuk summary
|
||||
type AggregateData struct {
|
||||
TotalActive int `json:"total_active"`
|
||||
TotalDraft int `json:"total_draft"`
|
||||
TotalInactive int `json:"total_inactive"`
|
||||
ByStatus map[string]int `json:"by_status"`
|
||||
ByDinas map[string]int `json:"by_dinas,omitempty"`
|
||||
ByJenis map[string]int `json:"by_jenis,omitempty"`
|
||||
LastUpdated *time.Time `json:"last_updated,omitempty"`
|
||||
CreatedToday int `json:"created_today"`
|
||||
UpdatedToday int `json:"updated_today"`
|
||||
}
|
||||
|
||||
// Error response yang konsisten
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// BaseRequest contains common fields for all BPJS requests
|
||||
type BaseRequest struct {
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp,omitempty"`
|
||||
}
|
||||
|
||||
// BaseResponse contains common response fields
|
||||
type BaseResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
}
|
||||
|
||||
// ErrorResponse represents error response structure
|
||||
type ErrorResponseBpjs struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
Errors map[string]interface{} `json:"errors,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// PaginationRequest contains pagination parameters
|
||||
type PaginationRequest struct {
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
SortBy string `json:"sort_by,omitempty"`
|
||||
SortDir string `json:"sort_dir,omitempty" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
|
||||
// PaginationResponse contains pagination metadata
|
||||
type PaginationResponse struct {
|
||||
CurrentPage int `json:"current_page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalItems int64 `json:"total_items"`
|
||||
ItemsPerPage int `json:"items_per_page"`
|
||||
HasNext bool `json:"has_next"`
|
||||
HasPrev bool `json:"has_previous"`
|
||||
}
|
||||
|
||||
// MetaInfo contains additional metadata
|
||||
type MetaInfo struct {
|
||||
Version string `json:"version"`
|
||||
Environment string `json:"environment"`
|
||||
ServerTime string `json:"server_time"`
|
||||
}
|
||||
|
||||
func GetStatusCodeFromMeta(metaCode interface{}) int {
|
||||
statusCode := http.StatusOK
|
||||
|
||||
if metaCode != nil {
|
||||
switch v := metaCode.(type) {
|
||||
case string:
|
||||
if code, err := strconv.Atoi(v); err == nil {
|
||||
if code >= 100 && code <= 599 {
|
||||
statusCode = code
|
||||
} else {
|
||||
statusCode = http.StatusInternalServerError
|
||||
}
|
||||
} else {
|
||||
statusCode = http.StatusInternalServerError
|
||||
}
|
||||
case int:
|
||||
if v >= 100 && v <= 599 {
|
||||
statusCode = v
|
||||
} else {
|
||||
statusCode = http.StatusInternalServerError
|
||||
}
|
||||
case float64:
|
||||
code := int(v)
|
||||
if code >= 100 && code <= 599 {
|
||||
statusCode = code
|
||||
} else {
|
||||
statusCode = http.StatusInternalServerError
|
||||
}
|
||||
default:
|
||||
statusCode = http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
return statusCode
|
||||
}
|
||||
|
||||
// Validation constants
|
||||
const (
|
||||
StatusDraft = "draft"
|
||||
StatusActive = "active"
|
||||
StatusInactive = "inactive"
|
||||
StatusDeleted = "deleted"
|
||||
)
|
||||
|
||||
// ValidStatuses untuk validasi
|
||||
var ValidStatuses = []string{StatusDraft, StatusActive, StatusInactive}
|
||||
|
||||
// IsValidStatus helper function
|
||||
func IsValidStatus(status string) bool {
|
||||
for _, validStatus := range ValidStatuses {
|
||||
if status == validStatus {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
228
internal/models/retribusi/retribusi.go
Normal file
228
internal/models/retribusi/retribusi.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package retribusi
|
||||
|
||||
import (
|
||||
"api-service/internal/models"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Retribusi represents the data structure for the retribusi table
|
||||
// with proper null handling and optimized JSON marshaling
|
||||
type Retribusi struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Status string `json:"status" db:"status"`
|
||||
Sort models.NullableInt32 `json:"sort,omitempty" db:"sort"`
|
||||
UserCreated models.NullableString `json:"user_created,omitempty" db:"user_created"`
|
||||
DateCreated models.NullableTime `json:"date_created,omitempty" db:"date_created"`
|
||||
UserUpdated models.NullableString `json:"user_updated,omitempty" db:"user_updated"`
|
||||
DateUpdated models.NullableTime `json:"date_updated,omitempty" db:"date_updated"`
|
||||
Jenis models.NullableString `json:"jenis,omitempty" db:"Jenis"`
|
||||
Pelayanan models.NullableString `json:"pelayanan,omitempty" db:"Pelayanan"`
|
||||
Dinas models.NullableString `json:"dinas,omitempty" db:"Dinas"`
|
||||
KelompokObyek models.NullableString `json:"kelompok_obyek,omitempty" db:"Kelompok_obyek"`
|
||||
KodeTarif models.NullableString `json:"kode_tarif,omitempty" db:"Kode_tarif"`
|
||||
Tarif models.NullableString `json:"tarif,omitempty" db:"Tarif"`
|
||||
Satuan models.NullableString `json:"satuan,omitempty" db:"Satuan"`
|
||||
TarifOvertime models.NullableString `json:"tarif_overtime,omitempty" db:"Tarif_overtime"`
|
||||
SatuanOvertime models.NullableString `json:"satuan_overtime,omitempty" db:"Satuan_overtime"`
|
||||
RekeningPokok models.NullableString `json:"rekening_pokok,omitempty" db:"Rekening_pokok"`
|
||||
RekeningDenda models.NullableString `json:"rekening_denda,omitempty" db:"Rekening_denda"`
|
||||
Uraian1 models.NullableString `json:"uraian_1,omitempty" db:"Uraian_1"`
|
||||
Uraian2 models.NullableString `json:"uraian_2,omitempty" db:"Uraian_2"`
|
||||
Uraian3 models.NullableString `json:"uraian_3,omitempty" db:"Uraian_3"`
|
||||
}
|
||||
|
||||
// Custom JSON marshaling untuk Retribusi agar NULL values tidak muncul di response
|
||||
func (r Retribusi) MarshalJSON() ([]byte, error) {
|
||||
type Alias Retribusi
|
||||
aux := &struct {
|
||||
Sort *int `json:"sort,omitempty"`
|
||||
UserCreated *string `json:"user_created,omitempty"`
|
||||
DateCreated *time.Time `json:"date_created,omitempty"`
|
||||
UserUpdated *string `json:"user_updated,omitempty"`
|
||||
DateUpdated *time.Time `json:"date_updated,omitempty"`
|
||||
Jenis *string `json:"jenis,omitempty"`
|
||||
Pelayanan *string `json:"pelayanan,omitempty"`
|
||||
Dinas *string `json:"dinas,omitempty"`
|
||||
KelompokObyek *string `json:"kelompok_obyek,omitempty"`
|
||||
KodeTarif *string `json:"kode_tarif,omitempty"`
|
||||
Tarif *string `json:"tarif,omitempty"`
|
||||
Satuan *string `json:"satuan,omitempty"`
|
||||
TarifOvertime *string `json:"tarif_overtime,omitempty"`
|
||||
SatuanOvertime *string `json:"satuan_overtime,omitempty"`
|
||||
RekeningPokok *string `json:"rekening_pokok,omitempty"`
|
||||
RekeningDenda *string `json:"rekening_denda,omitempty"`
|
||||
Uraian1 *string `json:"uraian_1,omitempty"`
|
||||
Uraian2 *string `json:"uraian_2,omitempty"`
|
||||
Uraian3 *string `json:"uraian_3,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(&r),
|
||||
}
|
||||
|
||||
// Convert NullableInt32 to pointer
|
||||
if r.Sort.Valid {
|
||||
sort := int(r.Sort.Int32)
|
||||
aux.Sort = &sort
|
||||
}
|
||||
if r.UserCreated.Valid {
|
||||
aux.UserCreated = &r.UserCreated.String
|
||||
}
|
||||
if r.DateCreated.Valid {
|
||||
aux.DateCreated = &r.DateCreated.Time
|
||||
}
|
||||
if r.UserUpdated.Valid {
|
||||
aux.UserUpdated = &r.UserUpdated.String
|
||||
}
|
||||
if r.DateUpdated.Valid {
|
||||
aux.DateUpdated = &r.DateUpdated.Time
|
||||
}
|
||||
if r.Jenis.Valid {
|
||||
aux.Jenis = &r.Jenis.String
|
||||
}
|
||||
if r.Pelayanan.Valid {
|
||||
aux.Pelayanan = &r.Pelayanan.String
|
||||
}
|
||||
if r.Dinas.Valid {
|
||||
aux.Dinas = &r.Dinas.String
|
||||
}
|
||||
if r.KelompokObyek.Valid {
|
||||
aux.KelompokObyek = &r.KelompokObyek.String
|
||||
}
|
||||
if r.KodeTarif.Valid {
|
||||
aux.KodeTarif = &r.KodeTarif.String
|
||||
}
|
||||
if r.Tarif.Valid {
|
||||
aux.Tarif = &r.Tarif.String
|
||||
}
|
||||
if r.Satuan.Valid {
|
||||
aux.Satuan = &r.Satuan.String
|
||||
}
|
||||
if r.TarifOvertime.Valid {
|
||||
aux.TarifOvertime = &r.TarifOvertime.String
|
||||
}
|
||||
if r.SatuanOvertime.Valid {
|
||||
aux.SatuanOvertime = &r.SatuanOvertime.String
|
||||
}
|
||||
if r.RekeningPokok.Valid {
|
||||
aux.RekeningPokok = &r.RekeningPokok.String
|
||||
}
|
||||
if r.RekeningDenda.Valid {
|
||||
aux.RekeningDenda = &r.RekeningDenda.String
|
||||
}
|
||||
if r.Uraian1.Valid {
|
||||
aux.Uraian1 = &r.Uraian1.String
|
||||
}
|
||||
if r.Uraian2.Valid {
|
||||
aux.Uraian2 = &r.Uraian2.String
|
||||
}
|
||||
if r.Uraian3.Valid {
|
||||
aux.Uraian3 = &r.Uraian3.String
|
||||
}
|
||||
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
|
||||
// Helper methods untuk mendapatkan nilai yang aman
|
||||
func (r *Retribusi) GetJenis() string {
|
||||
if r.Jenis.Valid {
|
||||
return r.Jenis.String
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r *Retribusi) GetDinas() string {
|
||||
if r.Dinas.Valid {
|
||||
return r.Dinas.String
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r *Retribusi) GetTarif() string {
|
||||
if r.Tarif.Valid {
|
||||
return r.Tarif.String
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Response struct untuk GET by ID - diperbaiki struktur
|
||||
type RetribusiGetByIDResponse struct {
|
||||
Message string `json:"message"`
|
||||
Data *Retribusi `json:"data"`
|
||||
}
|
||||
|
||||
// Request struct untuk create - dioptimalkan dengan validasi
|
||||
type RetribusiCreateRequest struct {
|
||||
Status string `json:"status" validate:"required,oneof=draft active inactive"`
|
||||
Jenis *string `json:"jenis,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
Pelayanan *string `json:"pelayanan,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
Dinas *string `json:"dinas,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
KelompokObyek *string `json:"kelompok_obyek,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
KodeTarif *string `json:"kode_tarif,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
Uraian1 *string `json:"uraian_1,omitempty"`
|
||||
Uraian2 *string `json:"uraian_2,omitempty"`
|
||||
Uraian3 *string `json:"uraian_3,omitempty"`
|
||||
Tarif *string `json:"tarif,omitempty" validate:"omitempty,numeric"`
|
||||
Satuan *string `json:"satuan,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
TarifOvertime *string `json:"tarif_overtime,omitempty" validate:"omitempty,numeric"`
|
||||
SatuanOvertime *string `json:"satuan_overtime,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
RekeningPokok *string `json:"rekening_pokok,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
RekeningDenda *string `json:"rekening_denda,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
}
|
||||
|
||||
// Response struct untuk create
|
||||
type RetribusiCreateResponse struct {
|
||||
Message string `json:"message"`
|
||||
Data *Retribusi `json:"data"`
|
||||
}
|
||||
|
||||
// Update request - sama seperti create tapi dengan ID
|
||||
type RetribusiUpdateRequest struct {
|
||||
ID string `json:"-" validate:"required,uuid4"` // ID dari URL path
|
||||
Status string `json:"status" validate:"required,oneof=draft active inactive"`
|
||||
Jenis *string `json:"jenis,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
Pelayanan *string `json:"pelayanan,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
Dinas *string `json:"dinas,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
KelompokObyek *string `json:"kelompok_obyek,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
KodeTarif *string `json:"kode_tarif,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
Uraian1 *string `json:"uraian_1,omitempty"`
|
||||
Uraian2 *string `json:"uraian_2,omitempty"`
|
||||
Uraian3 *string `json:"uraian_3,omitempty"`
|
||||
Tarif *string `json:"tarif,omitempty" validate:"omitempty,numeric"`
|
||||
Satuan *string `json:"satuan,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
TarifOvertime *string `json:"tarif_overtime,omitempty" validate:"omitempty,numeric"`
|
||||
SatuanOvertime *string `json:"satuan_overtime,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
RekeningPokok *string `json:"rekening_pokok,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
RekeningDenda *string `json:"rekening_denda,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
}
|
||||
|
||||
// Response struct untuk update
|
||||
type RetribusiUpdateResponse struct {
|
||||
Message string `json:"message"`
|
||||
Data *Retribusi `json:"data"`
|
||||
}
|
||||
|
||||
// Response struct untuk delete
|
||||
type RetribusiDeleteResponse struct {
|
||||
Message string `json:"message"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// Enhanced GET response dengan pagination dan aggregation
|
||||
type RetribusiGetResponse struct {
|
||||
Message string `json:"message"`
|
||||
Data []Retribusi `json:"data"`
|
||||
Meta models.MetaResponse `json:"meta"`
|
||||
Summary *models.AggregateData `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// Filter struct untuk query parameters
|
||||
type RetribusiFilter struct {
|
||||
Status *string `json:"status,omitempty" form:"status"`
|
||||
Jenis *string `json:"jenis,omitempty" form:"jenis"`
|
||||
Dinas *string `json:"dinas,omitempty" form:"dinas"`
|
||||
KelompokObyek *string `json:"kelompok_obyek,omitempty" form:"kelompok_obyek"`
|
||||
Search *string `json:"search,omitempty" form:"search"`
|
||||
DateFrom *time.Time `json:"date_from,omitempty" form:"date_from"`
|
||||
DateTo *time.Time `json:"date_to,omitempty" form:"date_to"`
|
||||
}
|
||||
106
internal/models/validation.go
Normal file
106
internal/models/validation.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// CustomValidator wraps the validator
|
||||
type CustomValidator struct {
|
||||
Validator *validator.Validate
|
||||
}
|
||||
|
||||
// Validate validates struct
|
||||
func (cv *CustomValidator) Validate(i interface{}) error {
|
||||
return cv.Validator.Struct(i)
|
||||
}
|
||||
|
||||
// RegisterCustomValidations registers custom validation rules
|
||||
func RegisterCustomValidations(v *validator.Validate) {
|
||||
// Validate Indonesian phone number
|
||||
v.RegisterValidation("indonesian_phone", validateIndonesianPhone)
|
||||
|
||||
// Validate BPJS card number format
|
||||
v.RegisterValidation("bpjs_card", validateBPJSCard)
|
||||
|
||||
// Validate Indonesian NIK
|
||||
v.RegisterValidation("indonesian_nik", validateIndonesianNIK)
|
||||
|
||||
// Validate date format YYYY-MM-DD
|
||||
v.RegisterValidation("date_format", validateDateFormat)
|
||||
|
||||
// Validate ICD-10 code format
|
||||
v.RegisterValidation("icd10", validateICD10)
|
||||
|
||||
// Validate ICD-9-CM procedure code
|
||||
v.RegisterValidation("icd9cm", validateICD9CM)
|
||||
}
|
||||
|
||||
func validateIndonesianPhone(fl validator.FieldLevel) bool {
|
||||
phone := fl.Field().String()
|
||||
if phone == "" {
|
||||
return true // Optional field
|
||||
}
|
||||
|
||||
// Indonesian phone number pattern: +62, 62, 08, or 8
|
||||
pattern := `^(\+?62|0?8)[1-9][0-9]{7,11}$`
|
||||
matched, _ := regexp.MatchString(pattern, phone)
|
||||
return matched
|
||||
}
|
||||
|
||||
func validateBPJSCard(fl validator.FieldLevel) bool {
|
||||
card := fl.Field().String()
|
||||
if len(card) != 13 {
|
||||
return false
|
||||
}
|
||||
|
||||
// BPJS card should be numeric
|
||||
pattern := `^\d{13}$`
|
||||
matched, _ := regexp.MatchString(pattern, card)
|
||||
return matched
|
||||
}
|
||||
|
||||
func validateIndonesianNIK(fl validator.FieldLevel) bool {
|
||||
nik := fl.Field().String()
|
||||
if len(nik) != 16 {
|
||||
return false
|
||||
}
|
||||
|
||||
// NIK should be numeric
|
||||
pattern := `^\d{16}$`
|
||||
matched, _ := regexp.MatchString(pattern, nik)
|
||||
return matched
|
||||
}
|
||||
|
||||
func validateDateFormat(fl validator.FieldLevel) bool {
|
||||
dateStr := fl.Field().String()
|
||||
_, err := time.Parse("2006-01-02", dateStr)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func validateICD10(fl validator.FieldLevel) bool {
|
||||
code := fl.Field().String()
|
||||
if code == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Basic ICD-10 pattern: Letter followed by 2 digits, optional dot and more digits
|
||||
pattern := `^[A-Z]\d{2}(\.\d+)?$`
|
||||
matched, _ := regexp.MatchString(pattern, strings.ToUpper(code))
|
||||
return matched
|
||||
}
|
||||
|
||||
func validateICD9CM(fl validator.FieldLevel) bool {
|
||||
code := fl.Field().String()
|
||||
if code == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Basic ICD-9-CM procedure pattern: 2-4 digits with optional decimal
|
||||
pattern := `^\d{2,4}(\.\d+)?$`
|
||||
matched, _ := regexp.MatchString(pattern, code)
|
||||
return matched
|
||||
}
|
||||
774
internal/routes/v1/routes.go
Normal file
774
internal/routes/v1/routes.go
Normal file
@@ -0,0 +1,774 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"api-service/internal/config"
|
||||
"api-service/internal/database"
|
||||
authHandlers "api-service/internal/handlers/auth"
|
||||
healthcheckHandlers "api-service/internal/handlers/healthcheck"
|
||||
retribusiHandlers "api-service/internal/handlers/retribusi"
|
||||
"api-service/internal/handlers/websocket"
|
||||
websocketHandlers "api-service/internal/handlers/websocket"
|
||||
"api-service/internal/middleware"
|
||||
services "api-service/internal/services/auth"
|
||||
"api-service/pkg/logger"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
)
|
||||
|
||||
func RegisterRoutes(cfg *config.Config) *gin.Engine {
|
||||
router := gin.New()
|
||||
|
||||
// Initialize auth middleware configuration
|
||||
middleware.InitializeAuth(cfg)
|
||||
|
||||
// Add global middleware
|
||||
router.Use(middleware.CORSConfig())
|
||||
router.Use(middleware.ErrorHandler())
|
||||
router.Use(logger.RequestLoggerMiddleware(logger.Default()))
|
||||
router.Use(gin.Recovery())
|
||||
|
||||
// Initialize services with error handling
|
||||
authService := services.NewAuthService(cfg)
|
||||
if authService == nil {
|
||||
logger.Fatal("Failed to initialize auth service")
|
||||
}
|
||||
|
||||
// Initialize database service
|
||||
dbService := database.New(cfg)
|
||||
|
||||
// Initialize WebSocket handler with enhanced features
|
||||
websocketHandler := websocketHandlers.NewWebSocketHandler(cfg, dbService)
|
||||
|
||||
// =============================================================================
|
||||
// HEALTH CHECK & SYSTEM ROUTES
|
||||
// =============================================================================
|
||||
|
||||
healthCheckHandler := healthcheckHandlers.NewHealthCheckHandler(dbService)
|
||||
sistem := router.Group("/api/sistem")
|
||||
{
|
||||
sistem.GET("/health", healthCheckHandler.CheckHealth)
|
||||
sistem.GET("/databases", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"databases": dbService.ListDBs(),
|
||||
"health": dbService.Health(),
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
sistem.GET("/info", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"service": "API Service v1.0.0",
|
||||
"websocket_active": true,
|
||||
"connected_clients": websocketHandler.GetConnectedClients(),
|
||||
"databases": dbService.ListDBs(),
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SWAGGER DOCUMENTATION
|
||||
// =============================================================================
|
||||
|
||||
router.GET("/swagger/*any", ginSwagger.WrapHandler(
|
||||
swaggerFiles.Handler,
|
||||
ginSwagger.DefaultModelsExpandDepth(-1),
|
||||
ginSwagger.DeepLinking(true),
|
||||
))
|
||||
|
||||
// =============================================================================
|
||||
// WEBSOCKET TEST CLIENT
|
||||
// =============================================================================
|
||||
|
||||
// router.GET("/websocket-test", func(c *gin.Context) {
|
||||
// c.Header("Content-Type", "text/html")
|
||||
// c.String(http.StatusOK, getWebSocketTestHTML())
|
||||
// })
|
||||
|
||||
// =============================================================================
|
||||
// API v1 GROUP
|
||||
// =============================================================================
|
||||
|
||||
v1 := router.Group("/api/v1")
|
||||
|
||||
// =============================================================================
|
||||
// PUBLIC ROUTES (No Authentication Required)
|
||||
// =============================================================================
|
||||
|
||||
// Authentication routes
|
||||
authHandler := authHandlers.NewAuthHandler(authService)
|
||||
tokenHandler := authHandlers.NewTokenHandler(authService)
|
||||
|
||||
// Basic auth routes
|
||||
v1.POST("/auth/login", authHandler.Login)
|
||||
v1.POST("/auth/register", authHandler.Register)
|
||||
v1.POST("/auth/refresh", authHandler.RefreshToken)
|
||||
|
||||
// Token generation routes
|
||||
v1.POST("/token/generate", tokenHandler.GenerateToken)
|
||||
v1.POST("/token/generate-direct", tokenHandler.GenerateTokenDirect)
|
||||
|
||||
// =============================================================================
|
||||
// WEBSOCKET ROUTES
|
||||
// =============================================================================
|
||||
|
||||
// Main WebSocket endpoint with enhanced features
|
||||
v1.GET("/ws", websocketHandler.HandleWebSocket)
|
||||
|
||||
// WebSocket management API
|
||||
wsAPI := router.Group("/api/websocket")
|
||||
{
|
||||
// =============================================================================
|
||||
// BASIC BROADCASTING
|
||||
// =============================================================================
|
||||
|
||||
wsAPI.POST("/broadcast", func(c *gin.Context) {
|
||||
var req struct {
|
||||
Type string `json:"type"`
|
||||
Message interface{} `json:"message"`
|
||||
Database string `json:"database,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
websocketHandler.BroadcastMessage(req.Type, req.Message)
|
||||
c.JSON(200, gin.H{
|
||||
"status": "broadcast sent",
|
||||
"clients_count": websocketHandler.GetConnectedClients(),
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
|
||||
wsAPI.POST("/broadcast/room/:room", func(c *gin.Context) {
|
||||
room := c.Param("room")
|
||||
var req struct {
|
||||
Type string `json:"type"`
|
||||
Message interface{} `json:"message"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
websocketHandler.BroadcastToRoom(room, req.Type, req.Message)
|
||||
c.JSON(200, gin.H{
|
||||
"status": "room broadcast sent",
|
||||
"room": room,
|
||||
"clients_count": websocketHandler.GetRoomClientCount(room), // Fix: gunakan GetRoomClientCount
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// ENHANCED CLIENT TARGETING
|
||||
// =============================================================================
|
||||
|
||||
wsAPI.POST("/send/:clientId", func(c *gin.Context) {
|
||||
clientID := c.Param("clientId")
|
||||
var req struct {
|
||||
Type string `json:"type"`
|
||||
Message interface{} `json:"message"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
websocketHandler.SendToClient(clientID, req.Type, req.Message)
|
||||
c.JSON(200, gin.H{
|
||||
"status": "message sent",
|
||||
"client_id": clientID,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
|
||||
// Send to client by static ID
|
||||
wsAPI.POST("/send/static/:staticId", func(c *gin.Context) {
|
||||
staticID := c.Param("staticId")
|
||||
logger.Infof("Sending message to static client: %s", staticID)
|
||||
var req struct {
|
||||
Type string `json:"type"`
|
||||
Message interface{} `json:"message"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
success := websocketHandler.SendToClientByStaticID(staticID, req.Type, req.Message)
|
||||
if success {
|
||||
c.JSON(200, gin.H{
|
||||
"status": "message sent to static client",
|
||||
"static_id": staticID,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
} else {
|
||||
c.JSON(404, gin.H{
|
||||
"error": "static client not found",
|
||||
"static_id": staticID,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Broadcast to all clients from specific IP
|
||||
wsAPI.POST("/broadcast/ip/:ipAddress", func(c *gin.Context) {
|
||||
ipAddress := c.Param("ipAddress")
|
||||
var req struct {
|
||||
Type string `json:"type"`
|
||||
Message interface{} `json:"message"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
count := websocketHandler.BroadcastToIP(ipAddress, req.Type, req.Message)
|
||||
c.JSON(200, gin.H{
|
||||
"status": "ip broadcast sent",
|
||||
"ip_address": ipAddress,
|
||||
"clients_count": count,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// CLIENT INFORMATION & STATISTICS
|
||||
// =============================================================================
|
||||
|
||||
wsAPI.GET("/stats", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"connected_clients": websocketHandler.GetConnectedClients(),
|
||||
"databases": dbService.ListDBs(),
|
||||
"database_health": dbService.Health(),
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
|
||||
wsAPI.GET("/stats/detailed", func(c *gin.Context) {
|
||||
stats := websocketHandler.GetDetailedStats()
|
||||
c.JSON(200, gin.H{
|
||||
"stats": stats,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
|
||||
wsAPI.GET("/clients", func(c *gin.Context) {
|
||||
clients := websocketHandler.GetAllClients()
|
||||
c.JSON(200, gin.H{
|
||||
"clients": clients,
|
||||
"count": len(clients),
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
|
||||
// Fix: Perbaiki GetClientsByIP untuk menggunakan ClientInfo
|
||||
wsAPI.GET("/clients/by-ip/:ipAddress", func(c *gin.Context) {
|
||||
ipAddress := c.Param("ipAddress")
|
||||
client := websocketHandler.GetClientsByIP(ipAddress)
|
||||
if client == nil {
|
||||
c.JSON(404, gin.H{
|
||||
"error": "client not found",
|
||||
"ip_address": ipAddress,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Use ClientInfo struct instead of direct field access
|
||||
clientInfo := websocketHandler.GetAllClients()
|
||||
var targetClientInfo *websocket.ClientInfo
|
||||
for i := range clientInfo {
|
||||
if clientInfo[i].ID == ipAddress {
|
||||
targetClientInfo = &clientInfo[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if targetClientInfo == nil {
|
||||
c.JSON(404, gin.H{
|
||||
"error": "ipAddress not found",
|
||||
"client_id": ipAddress,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"client": map[string]interface{}{
|
||||
"id": targetClientInfo.ID,
|
||||
"static_id": targetClientInfo.StaticID,
|
||||
"ip_address": targetClientInfo.IPAddress,
|
||||
"user_id": targetClientInfo.UserID,
|
||||
"room": targetClientInfo.Room,
|
||||
"connected_at": targetClientInfo.ConnectedAt.Unix(), // Fixed: use exported field
|
||||
"last_ping": targetClientInfo.LastPing.Unix(), // Fixed: use exported field
|
||||
},
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
// Fix: Perbaiki GetClientByID response
|
||||
wsAPI.GET("/client/:clientId", func(c *gin.Context) {
|
||||
clientID := c.Param("clientId")
|
||||
client := websocketHandler.GetClientByID(clientID)
|
||||
|
||||
if client == nil {
|
||||
c.JSON(404, gin.H{
|
||||
"error": "client not found",
|
||||
"client_id": clientID,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Use ClientInfo struct instead of direct field access
|
||||
clientInfo := websocketHandler.GetAllClients()
|
||||
var targetClientInfo *websocket.ClientInfo
|
||||
for i := range clientInfo {
|
||||
if clientInfo[i].ID == clientID {
|
||||
targetClientInfo = &clientInfo[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if targetClientInfo == nil {
|
||||
c.JSON(404, gin.H{
|
||||
"error": "client not found",
|
||||
"client_id": clientID,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"client": map[string]interface{}{
|
||||
"id": targetClientInfo.ID,
|
||||
"static_id": targetClientInfo.StaticID,
|
||||
"ip_address": targetClientInfo.IPAddress,
|
||||
"user_id": targetClientInfo.UserID,
|
||||
"room": targetClientInfo.Room,
|
||||
"connected_at": targetClientInfo.ConnectedAt.Unix(), // Fixed: use exported field
|
||||
"last_ping": targetClientInfo.LastPing.Unix(), // Fixed: use exported field
|
||||
},
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
|
||||
// Fix: Perbaiki GetClientByStaticID response
|
||||
wsAPI.GET("/client/static/:staticId", func(c *gin.Context) {
|
||||
staticID := c.Param("staticId")
|
||||
client := websocketHandler.GetClientByStaticID(staticID)
|
||||
|
||||
if client == nil {
|
||||
c.JSON(404, gin.H{
|
||||
"error": "static client not found",
|
||||
"static_id": staticID,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Use ClientInfo struct instead of direct field access
|
||||
clientInfo := websocketHandler.GetAllClients()
|
||||
var targetClientInfo *websocket.ClientInfo
|
||||
for i := range clientInfo {
|
||||
if clientInfo[i].StaticID == staticID {
|
||||
targetClientInfo = &clientInfo[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if targetClientInfo == nil {
|
||||
c.JSON(404, gin.H{
|
||||
"error": "static client not found",
|
||||
"static_id": staticID,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"client": map[string]interface{}{
|
||||
"id": targetClientInfo.ID,
|
||||
"static_id": targetClientInfo.StaticID,
|
||||
"ip_address": targetClientInfo.IPAddress,
|
||||
"user_id": targetClientInfo.UserID,
|
||||
"room": targetClientInfo.Room,
|
||||
"connected_at": targetClientInfo.ConnectedAt.Unix(), // Fixed: use exported field
|
||||
"last_ping": targetClientInfo.LastPing.Unix(), // Fixed: use exported field
|
||||
},
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// ACTIVE CLIENTS & CLEANUP
|
||||
// =============================================================================
|
||||
|
||||
// Tambahkan endpoint untuk active clients
|
||||
wsAPI.GET("/clients/active", func(c *gin.Context) {
|
||||
// Default: clients active dalam 5 menit terakhir
|
||||
minutes := c.DefaultQuery("minutes", "5")
|
||||
minutesInt, err := strconv.Atoi(minutes)
|
||||
if err != nil {
|
||||
minutesInt = 5
|
||||
}
|
||||
|
||||
activeClients := websocketHandler.GetActiveClients(time.Duration(minutesInt) * time.Minute)
|
||||
c.JSON(200, gin.H{
|
||||
"active_clients": activeClients,
|
||||
"count": len(activeClients),
|
||||
"threshold_minutes": minutesInt,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
|
||||
// Tambahkan endpoint untuk cleanup inactive clients
|
||||
wsAPI.POST("/cleanup/inactive", func(c *gin.Context) {
|
||||
var req struct {
|
||||
InactiveMinutes int `json:"inactive_minutes"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
req.InactiveMinutes = 30 // Default 30 minutes
|
||||
}
|
||||
|
||||
if req.InactiveMinutes <= 0 {
|
||||
req.InactiveMinutes = 30
|
||||
}
|
||||
|
||||
cleanedCount := websocketHandler.CleanupInactiveClients(time.Duration(req.InactiveMinutes) * time.Minute)
|
||||
c.JSON(200, gin.H{
|
||||
"status": "cleanup completed",
|
||||
"cleaned_clients": cleanedCount,
|
||||
"inactive_minutes": req.InactiveMinutes,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// DATABASE NOTIFICATIONS
|
||||
// =============================================================================
|
||||
|
||||
wsAPI.POST("/notify/:database/:channel", func(c *gin.Context) {
|
||||
database := c.Param("database")
|
||||
channel := c.Param("channel")
|
||||
|
||||
var req struct {
|
||||
Payload interface{} `json:"payload"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
payloadJSON, _ := json.Marshal(req.Payload)
|
||||
err := dbService.NotifyChange(database, channel, string(payloadJSON))
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"error": err.Error(),
|
||||
"database": database,
|
||||
"channel": channel,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": "notification sent",
|
||||
"database": database,
|
||||
"channel": channel,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
|
||||
// Test database notification
|
||||
wsAPI.POST("/test-notification", func(c *gin.Context) {
|
||||
var req struct {
|
||||
Database string `json:"database"`
|
||||
Channel string `json:"channel"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Default values
|
||||
if req.Database == "" {
|
||||
req.Database = "default"
|
||||
}
|
||||
if req.Channel == "" {
|
||||
req.Channel = "system_changes"
|
||||
}
|
||||
if req.Message == "" {
|
||||
req.Message = "Test notification from API"
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"operation": "API_TEST",
|
||||
"table": "manual_test",
|
||||
"data": map[string]interface{}{
|
||||
"message": req.Message,
|
||||
"test_data": req.Data,
|
||||
"timestamp": time.Now().Unix(),
|
||||
},
|
||||
}
|
||||
|
||||
payloadJSON, _ := json.Marshal(payload)
|
||||
err := dbService.NotifyChange(req.Database, req.Channel, string(payloadJSON))
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": "test notification sent",
|
||||
"database": req.Database,
|
||||
"channel": req.Channel,
|
||||
"payload": payload,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// ROOM MANAGEMENT
|
||||
// =============================================================================
|
||||
|
||||
wsAPI.GET("/rooms", func(c *gin.Context) {
|
||||
rooms := websocketHandler.GetAllRooms()
|
||||
c.JSON(200, gin.H{
|
||||
"rooms": rooms,
|
||||
"count": len(rooms),
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
|
||||
wsAPI.GET("/room/:room/clients", func(c *gin.Context) {
|
||||
room := c.Param("room")
|
||||
clientCount := websocketHandler.GetRoomClientCount(room)
|
||||
|
||||
// Get detailed room info
|
||||
allRooms := websocketHandler.GetAllRooms()
|
||||
roomClients := allRooms[room]
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"room": room,
|
||||
"client_count": clientCount,
|
||||
"clients": roomClients,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// MONITORING & DEBUGGING
|
||||
// =============================================================================
|
||||
|
||||
wsAPI.GET("/monitor", func(c *gin.Context) {
|
||||
monitor := websocketHandler.GetMonitoringData()
|
||||
c.JSON(200, monitor)
|
||||
})
|
||||
|
||||
wsAPI.POST("/ping-client/:clientId", func(c *gin.Context) {
|
||||
clientID := c.Param("clientId")
|
||||
websocketHandler.SendToClient(clientID, "server_ping", map[string]interface{}{
|
||||
"message": "Ping from server",
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
c.JSON(200, gin.H{
|
||||
"status": "ping sent",
|
||||
"client_id": clientID,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
|
||||
// Disconnect specific client
|
||||
wsAPI.POST("/disconnect/:clientId", func(c *gin.Context) {
|
||||
clientID := c.Param("clientId")
|
||||
success := websocketHandler.DisconnectClient(clientID)
|
||||
if success {
|
||||
c.JSON(200, gin.H{
|
||||
"status": "client disconnected",
|
||||
"client_id": clientID,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
} else {
|
||||
c.JSON(404, gin.H{
|
||||
"error": "client not found",
|
||||
"client_id": clientID,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// BULK OPERATIONS
|
||||
// =============================================================================
|
||||
|
||||
// Broadcast to multiple clients
|
||||
wsAPI.POST("/broadcast/bulk", func(c *gin.Context) {
|
||||
var req struct {
|
||||
ClientIDs []string `json:"client_ids"`
|
||||
Type string `json:"type"`
|
||||
Message interface{} `json:"message"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
for _, clientID := range req.ClientIDs {
|
||||
websocketHandler.SendToClient(clientID, req.Type, req.Message)
|
||||
successCount++
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": "bulk broadcast sent",
|
||||
"total_clients": len(req.ClientIDs),
|
||||
"success_count": successCount,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
|
||||
// Disconnect multiple clients
|
||||
wsAPI.POST("/disconnect/bulk", func(c *gin.Context) {
|
||||
var req struct {
|
||||
ClientIDs []string `json:"client_ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
for _, clientID := range req.ClientIDs {
|
||||
if websocketHandler.DisconnectClient(clientID) {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": "bulk disconnect completed",
|
||||
"total_clients": len(req.ClientIDs),
|
||||
"success_count": successCount,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PUBLISHED ROUTES
|
||||
// =============================================================================
|
||||
|
||||
// Retribusi endpoints with WebSocket notifications
|
||||
retribusiHandler := retribusiHandlers.NewRetribusiHandler()
|
||||
retribusiGroup := v1.Group("/retribusi")
|
||||
{
|
||||
retribusiGroup.GET("", retribusiHandler.GetRetribusi)
|
||||
retribusiGroup.GET("/dynamic", retribusiHandler.GetRetribusiDynamic)
|
||||
retribusiGroup.GET("/search", retribusiHandler.SearchRetribusiAdvanced)
|
||||
retribusiGroup.GET("/id/:id", retribusiHandler.GetRetribusiByID)
|
||||
|
||||
// POST/PUT/DELETE with automatic WebSocket notifications
|
||||
retribusiGroup.POST("", func(c *gin.Context) {
|
||||
retribusiHandler.CreateRetribusi(c)
|
||||
|
||||
// Trigger WebSocket notification after successful creation
|
||||
if c.Writer.Status() == 200 || c.Writer.Status() == 201 {
|
||||
websocketHandler.BroadcastMessage("retribusi_created", map[string]interface{}{
|
||||
"message": "New retribusi record created",
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
retribusiGroup.PUT("/id/:id", func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
retribusiHandler.UpdateRetribusi(c)
|
||||
|
||||
// Trigger WebSocket notification after successful update
|
||||
if c.Writer.Status() == 200 {
|
||||
websocketHandler.BroadcastMessage("retribusi_updated", map[string]interface{}{
|
||||
"message": "Retribusi record updated",
|
||||
"id": id,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
retribusiGroup.DELETE("/id/:id", func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
retribusiHandler.DeleteRetribusi(c)
|
||||
|
||||
// Trigger WebSocket notification after successful deletion
|
||||
if c.Writer.Status() == 200 {
|
||||
websocketHandler.BroadcastMessage("retribusi_deleted", map[string]interface{}{
|
||||
"message": "Retribusi record deleted",
|
||||
"id": id,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROTECTED ROUTES (Authentication Required)
|
||||
// =============================================================================
|
||||
|
||||
protected := v1.Group("/")
|
||||
protected.Use(middleware.ConfigurableAuthMiddleware(cfg))
|
||||
|
||||
// Protected WebSocket management (optional)
|
||||
protectedWS := protected.Group("/ws-admin")
|
||||
{
|
||||
protectedWS.GET("/stats", func(c *gin.Context) {
|
||||
detailedStats := websocketHandler.GetDetailedStats()
|
||||
c.JSON(200, gin.H{
|
||||
"admin_stats": detailedStats,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
|
||||
protectedWS.POST("/force-disconnect/:clientId", func(c *gin.Context) {
|
||||
clientID := c.Param("clientId")
|
||||
success := websocketHandler.DisconnectClient(clientID)
|
||||
c.JSON(200, gin.H{
|
||||
"status": "force disconnect attempted",
|
||||
"client_id": clientID,
|
||||
"success": success,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
|
||||
protectedWS.POST("/cleanup/force", func(c *gin.Context) {
|
||||
var req struct {
|
||||
InactiveMinutes int `json:"inactive_minutes"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
req.InactiveMinutes = 10
|
||||
req.Force = false
|
||||
}
|
||||
|
||||
cleanedCount := websocketHandler.CleanupInactiveClients(time.Duration(req.InactiveMinutes) * time.Minute)
|
||||
c.JSON(200, gin.H{
|
||||
"status": "admin cleanup completed",
|
||||
"cleaned_clients": cleanedCount,
|
||||
"inactive_minutes": req.InactiveMinutes,
|
||||
"force": req.Force,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return router
|
||||
}
|
||||
53
internal/server/server.go
Normal file
53
internal/server/server.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
|
||||
"api-service/internal/config"
|
||||
"api-service/internal/database"
|
||||
v1 "api-service/internal/routes/v1"
|
||||
)
|
||||
|
||||
var dbService database.Service // Global variable to hold the database service instance
|
||||
|
||||
type Server struct {
|
||||
port int
|
||||
db database.Service
|
||||
}
|
||||
|
||||
func NewServer() *http.Server {
|
||||
// Load configuration
|
||||
cfg := config.LoadConfig()
|
||||
cfg.Validate()
|
||||
|
||||
port, _ := strconv.Atoi(os.Getenv("PORT"))
|
||||
if port == 0 {
|
||||
port = cfg.Server.Port
|
||||
}
|
||||
|
||||
if dbService == nil { // Check if the database service is already initialized
|
||||
dbService = database.New(cfg) // Initialize only once
|
||||
}
|
||||
|
||||
NewServer := &Server{
|
||||
port: port,
|
||||
db: dbService, // Use the global database service instance
|
||||
}
|
||||
|
||||
// Declare Server config
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", NewServer.port),
|
||||
Handler: v1.RegisterRoutes(cfg),
|
||||
IdleTimeout: time.Minute,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
169
internal/services/auth/auth.go
Normal file
169
internal/services/auth/auth.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"api-service/internal/config"
|
||||
models "api-service/internal/models/auth"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// AuthService handles authentication logic
|
||||
type AuthService struct {
|
||||
config *config.Config
|
||||
users map[string]*models.User // In-memory user store for demo
|
||||
}
|
||||
|
||||
// NewAuthService creates a new authentication service
|
||||
func NewAuthService(cfg *config.Config) *AuthService {
|
||||
// Initialize with demo users
|
||||
users := make(map[string]*models.User)
|
||||
|
||||
// Add demo users
|
||||
users["admin"] = &models.User{
|
||||
ID: "1",
|
||||
Username: "admin",
|
||||
Email: "admin@example.com",
|
||||
Password: "$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi", // password
|
||||
Role: "admin",
|
||||
}
|
||||
|
||||
users["user"] = &models.User{
|
||||
ID: "2",
|
||||
Username: "user",
|
||||
Email: "user@example.com",
|
||||
Password: "$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi", // password
|
||||
Role: "user",
|
||||
}
|
||||
|
||||
return &AuthService{
|
||||
config: cfg,
|
||||
users: users,
|
||||
}
|
||||
}
|
||||
|
||||
// Login authenticates user and generates JWT token
|
||||
func (s *AuthService) Login(username, password string) (*models.TokenResponse, error) {
|
||||
user, exists := s.users[username]
|
||||
if !exists {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
// Verify password
|
||||
err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
token, err := s.generateToken(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.TokenResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600, // 1 hour
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateToken creates a new JWT token for the user
|
||||
func (s *AuthService) generateToken(user *models.User) (string, error) {
|
||||
// Create claims
|
||||
claims := jwt.MapClaims{
|
||||
"user_id": user.ID,
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"exp": time.Now().Add(time.Hour * 1).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
|
||||
// Create token
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
// Sign token with secret key
|
||||
secretKey := []byte(s.getJWTSecret())
|
||||
return token.SignedString(secretKey)
|
||||
}
|
||||
|
||||
// GenerateTokenForUser generates a JWT token for a specific user
|
||||
func (s *AuthService) GenerateTokenForUser(user *models.User) (string, error) {
|
||||
// Create claims
|
||||
claims := jwt.MapClaims{
|
||||
"user_id": user.ID,
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"exp": time.Now().Add(time.Hour * 1).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
|
||||
// Create token
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
// Sign token with secret key
|
||||
secretKey := []byte(s.getJWTSecret())
|
||||
return token.SignedString(secretKey)
|
||||
}
|
||||
|
||||
// ValidateToken validates the JWT token
|
||||
func (s *AuthService) ValidateToken(tokenString string) (*models.JWTClaims, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return []byte(s.getJWTSecret()), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid claims")
|
||||
}
|
||||
|
||||
return &models.JWTClaims{
|
||||
UserID: claims["user_id"].(string),
|
||||
Username: claims["username"].(string),
|
||||
Email: claims["email"].(string),
|
||||
Role: claims["role"].(string),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getJWTSecret returns the JWT secret key
|
||||
func (s *AuthService) getJWTSecret() string {
|
||||
// In production, this should come from environment variables
|
||||
return "your-secret-key-change-this-in-production"
|
||||
}
|
||||
|
||||
// RegisterUser registers a new user (for demo purposes)
|
||||
func (s *AuthService) RegisterUser(username, email, password, role string) error {
|
||||
if _, exists := s.users[username]; exists {
|
||||
return errors.New("username already exists")
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.users[username] = &models.User{
|
||||
ID: string(rune(len(s.users) + 1)),
|
||||
Username: username,
|
||||
Email: email,
|
||||
Password: string(hashedPassword),
|
||||
Role: role,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
593
internal/utils/filters/dynamic_filter.go
Normal file
593
internal/utils/filters/dynamic_filter.go
Normal file
@@ -0,0 +1,593 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// FilterOperator represents supported filter operators
|
||||
type FilterOperator string
|
||||
|
||||
const (
|
||||
OpEqual FilterOperator = "_eq"
|
||||
OpNotEqual FilterOperator = "_neq"
|
||||
OpLike FilterOperator = "_like"
|
||||
OpILike FilterOperator = "_ilike"
|
||||
OpIn FilterOperator = "_in"
|
||||
OpNotIn FilterOperator = "_nin"
|
||||
OpGreaterThan FilterOperator = "_gt"
|
||||
OpGreaterThanEqual FilterOperator = "_gte"
|
||||
OpLessThan FilterOperator = "_lt"
|
||||
OpLessThanEqual FilterOperator = "_lte"
|
||||
OpBetween FilterOperator = "_between"
|
||||
OpNotBetween FilterOperator = "_nbetween"
|
||||
OpNull FilterOperator = "_null"
|
||||
OpNotNull FilterOperator = "_nnull"
|
||||
OpContains FilterOperator = "_contains"
|
||||
OpNotContains FilterOperator = "_ncontains"
|
||||
OpStartsWith FilterOperator = "_starts_with"
|
||||
OpEndsWith FilterOperator = "_ends_with"
|
||||
)
|
||||
|
||||
// DynamicFilter represents a single filter condition
|
||||
type DynamicFilter struct {
|
||||
Column string `json:"column"`
|
||||
Operator FilterOperator `json:"operator"`
|
||||
Value interface{} `json:"value"`
|
||||
LogicOp string `json:"logic_op,omitempty"` // AND, OR
|
||||
}
|
||||
|
||||
// FilterGroup represents a group of filters
|
||||
type FilterGroup struct {
|
||||
Filters []DynamicFilter `json:"filters"`
|
||||
LogicOp string `json:"logic_op"` // AND, OR
|
||||
}
|
||||
|
||||
// DynamicQuery represents the complete query structure
|
||||
type DynamicQuery struct {
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
Filters []FilterGroup `json:"filters,omitempty"`
|
||||
Sort []SortField `json:"sort,omitempty"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
GroupBy []string `json:"group_by,omitempty"`
|
||||
Having []FilterGroup `json:"having,omitempty"`
|
||||
}
|
||||
|
||||
// SortField represents sorting configuration
|
||||
type SortField struct {
|
||||
Column string `json:"column"`
|
||||
Order string `json:"order"` // ASC, DESC
|
||||
}
|
||||
|
||||
// QueryBuilder builds SQL queries from dynamic filters
|
||||
type QueryBuilder struct {
|
||||
tableName string
|
||||
columnMapping map[string]string // Maps API field names to DB column names
|
||||
allowedColumns map[string]bool // Security: only allow specified columns
|
||||
paramCounter int
|
||||
mu *sync.RWMutex
|
||||
}
|
||||
|
||||
// NewQueryBuilder creates a new query builder instance
|
||||
func NewQueryBuilder(tableName string) *QueryBuilder {
|
||||
return &QueryBuilder{
|
||||
tableName: tableName,
|
||||
columnMapping: make(map[string]string),
|
||||
allowedColumns: make(map[string]bool),
|
||||
paramCounter: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// SetColumnMapping sets the mapping between API field names and database column names
|
||||
func (qb *QueryBuilder) SetColumnMapping(mapping map[string]string) *QueryBuilder {
|
||||
qb.columnMapping = mapping
|
||||
return qb
|
||||
}
|
||||
|
||||
// SetAllowedColumns sets the list of allowed columns for security
|
||||
func (qb *QueryBuilder) SetAllowedColumns(columns []string) *QueryBuilder {
|
||||
qb.allowedColumns = make(map[string]bool)
|
||||
for _, col := range columns {
|
||||
qb.allowedColumns[col] = true
|
||||
}
|
||||
return qb
|
||||
}
|
||||
|
||||
// BuildQuery builds the complete SQL query
|
||||
func (qb *QueryBuilder) BuildQuery(query DynamicQuery) (string, []interface{}, error) {
|
||||
qb.paramCounter = 0
|
||||
|
||||
// Build SELECT clause
|
||||
selectClause := qb.buildSelectClause(query.Fields)
|
||||
|
||||
// Build FROM clause
|
||||
fromClause := fmt.Sprintf("FROM %s", qb.tableName)
|
||||
|
||||
// Build WHERE clause
|
||||
whereClause, whereArgs, err := qb.buildWhereClause(query.Filters)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
// Build ORDER BY clause
|
||||
orderClause := qb.buildOrderClause(query.Sort)
|
||||
|
||||
// Build GROUP BY clause
|
||||
groupClause := qb.buildGroupByClause(query.GroupBy)
|
||||
|
||||
// Build HAVING clause
|
||||
havingClause, havingArgs, err := qb.buildHavingClause(query.Having)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
// Combine all parts
|
||||
sqlParts := []string{selectClause, fromClause}
|
||||
args := []interface{}{}
|
||||
|
||||
if whereClause != "" {
|
||||
sqlParts = append(sqlParts, "WHERE "+whereClause)
|
||||
args = append(args, whereArgs...)
|
||||
}
|
||||
|
||||
if groupClause != "" {
|
||||
sqlParts = append(sqlParts, groupClause)
|
||||
}
|
||||
|
||||
if havingClause != "" {
|
||||
sqlParts = append(sqlParts, "HAVING "+havingClause)
|
||||
args = append(args, havingArgs...)
|
||||
}
|
||||
|
||||
if orderClause != "" {
|
||||
sqlParts = append(sqlParts, orderClause)
|
||||
}
|
||||
|
||||
// Add pagination
|
||||
if query.Limit > 0 {
|
||||
qb.paramCounter++
|
||||
sqlParts = append(sqlParts, fmt.Sprintf("LIMIT $%d", qb.paramCounter))
|
||||
args = append(args, query.Limit)
|
||||
}
|
||||
|
||||
if query.Offset > 0 {
|
||||
qb.paramCounter++
|
||||
sqlParts = append(sqlParts, fmt.Sprintf("OFFSET $%d", qb.paramCounter))
|
||||
args = append(args, query.Offset)
|
||||
}
|
||||
|
||||
sql := strings.Join(sqlParts, " ")
|
||||
return sql, args, nil
|
||||
}
|
||||
|
||||
// buildSelectClause builds the SELECT part of the query
|
||||
func (qb *QueryBuilder) buildSelectClause(fields []string) string {
|
||||
if len(fields) == 0 || (len(fields) == 1 && fields[0] == "*") {
|
||||
return "SELECT *"
|
||||
}
|
||||
|
||||
var selectedFields []string
|
||||
for _, field := range fields {
|
||||
if field == "*.*" || field == "*" {
|
||||
selectedFields = append(selectedFields, "*")
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's an expression (contains spaces, parentheses, etc.)
|
||||
if strings.Contains(field, " ") || strings.Contains(field, "(") || strings.Contains(field, ")") {
|
||||
// Expression, add as is
|
||||
selectedFields = append(selectedFields, field)
|
||||
continue
|
||||
}
|
||||
|
||||
// Security check: only allow specified columns (check original field name)
|
||||
if len(qb.allowedColumns) > 0 && !qb.allowedColumns[field] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Map field name if mapping exists
|
||||
if mappedCol, exists := qb.columnMapping[field]; exists {
|
||||
field = mappedCol
|
||||
}
|
||||
|
||||
selectedFields = append(selectedFields, fmt.Sprintf(`"%s"`, field))
|
||||
}
|
||||
|
||||
if len(selectedFields) == 0 {
|
||||
return "SELECT *"
|
||||
}
|
||||
|
||||
return "SELECT " + strings.Join(selectedFields, ", ")
|
||||
}
|
||||
|
||||
// buildWhereClause builds the WHERE part of the query
|
||||
func (qb *QueryBuilder) buildWhereClause(filterGroups []FilterGroup) (string, []interface{}, error) {
|
||||
if len(filterGroups) == 0 {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
var conditions []string
|
||||
var args []interface{}
|
||||
|
||||
for i, group := range filterGroups {
|
||||
groupCondition, groupArgs, err := qb.buildFilterGroup(group)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if groupCondition != "" {
|
||||
if i > 0 {
|
||||
logicOp := "AND"
|
||||
if group.LogicOp != "" {
|
||||
logicOp = strings.ToUpper(group.LogicOp)
|
||||
}
|
||||
conditions = append(conditions, logicOp)
|
||||
}
|
||||
|
||||
conditions = append(conditions, groupCondition)
|
||||
args = append(args, groupArgs...)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(conditions, " "), args, nil
|
||||
}
|
||||
|
||||
// buildFilterGroup builds conditions for a filter group
|
||||
func (qb *QueryBuilder) buildFilterGroup(group FilterGroup) (string, []interface{}, error) {
|
||||
if len(group.Filters) == 0 {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
var conditions []string
|
||||
var args []interface{}
|
||||
|
||||
for i, filter := range group.Filters {
|
||||
condition, filterArgs, err := qb.buildFilterCondition(filter)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if condition != "" {
|
||||
if i > 0 {
|
||||
logicOp := "AND"
|
||||
if filter.LogicOp != "" {
|
||||
logicOp = strings.ToUpper(filter.LogicOp)
|
||||
} else if group.LogicOp != "" {
|
||||
logicOp = strings.ToUpper(group.LogicOp)
|
||||
}
|
||||
conditions = append(conditions, logicOp)
|
||||
}
|
||||
|
||||
conditions = append(conditions, condition)
|
||||
args = append(args, filterArgs...)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(conditions, " "), args, nil
|
||||
}
|
||||
|
||||
// buildFilterCondition builds a single filter condition
|
||||
func (qb *QueryBuilder) buildFilterCondition(filter DynamicFilter) (string, []interface{}, error) {
|
||||
// Security check (check original field name)
|
||||
if len(qb.allowedColumns) > 0 && !qb.allowedColumns[filter.Column] {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
// Map column name if mapping exists
|
||||
column := filter.Column
|
||||
if mappedCol, exists := qb.columnMapping[column]; exists {
|
||||
column = mappedCol
|
||||
}
|
||||
|
||||
// Wrap column name in quotes for PostgreSQL
|
||||
column = fmt.Sprintf(`"%s"`, column)
|
||||
|
||||
switch filter.Operator {
|
||||
case OpEqual:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
return fmt.Sprintf("%s = $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil
|
||||
|
||||
case OpNotEqual:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
return fmt.Sprintf("%s != $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil
|
||||
|
||||
case OpLike:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
return fmt.Sprintf("%s LIKE $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil
|
||||
|
||||
case OpILike:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
return fmt.Sprintf("%s ILIKE $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil
|
||||
|
||||
case OpIn:
|
||||
values := qb.parseArrayValue(filter.Value)
|
||||
if len(values) == 0 {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
var placeholders []string
|
||||
var args []interface{}
|
||||
for _, val := range values {
|
||||
qb.paramCounter++
|
||||
placeholders = append(placeholders, fmt.Sprintf("$%d", qb.paramCounter))
|
||||
args = append(args, val)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s IN (%s)", column, strings.Join(placeholders, ", ")), args, nil
|
||||
|
||||
case OpNotIn:
|
||||
values := qb.parseArrayValue(filter.Value)
|
||||
if len(values) == 0 {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
var placeholders []string
|
||||
var args []interface{}
|
||||
for _, val := range values {
|
||||
qb.paramCounter++
|
||||
placeholders = append(placeholders, fmt.Sprintf("$%d", qb.paramCounter))
|
||||
args = append(args, val)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s NOT IN (%s)", column, strings.Join(placeholders, ", ")), args, nil
|
||||
|
||||
case OpGreaterThan:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
return fmt.Sprintf("%s > $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil
|
||||
|
||||
case OpGreaterThanEqual:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
return fmt.Sprintf("%s >= $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil
|
||||
|
||||
case OpLessThan:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
return fmt.Sprintf("%s < $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil
|
||||
|
||||
case OpLessThanEqual:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
return fmt.Sprintf("%s <= $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil
|
||||
|
||||
case OpBetween:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
values := qb.parseArrayValue(filter.Value)
|
||||
if len(values) != 2 {
|
||||
return "", nil, fmt.Errorf("between operator requires exactly 2 values")
|
||||
}
|
||||
qb.paramCounter++
|
||||
param1 := qb.paramCounter
|
||||
qb.paramCounter++
|
||||
param2 := qb.paramCounter
|
||||
return fmt.Sprintf("%s BETWEEN $%d AND $%d", column, param1, param2), []interface{}{values[0], values[1]}, nil
|
||||
|
||||
case OpNotBetween:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
values := qb.parseArrayValue(filter.Value)
|
||||
if len(values) != 2 {
|
||||
return "", nil, fmt.Errorf("not between operator requires exactly 2 values")
|
||||
}
|
||||
qb.paramCounter++
|
||||
param1 := qb.paramCounter
|
||||
qb.paramCounter++
|
||||
param2 := qb.paramCounter
|
||||
return fmt.Sprintf("%s NOT BETWEEN $%d AND $%d", column, param1, param2), []interface{}{values[0], values[1]}, nil
|
||||
|
||||
case OpNull:
|
||||
return fmt.Sprintf("%s IS NULL", column), nil, nil
|
||||
|
||||
case OpNotNull:
|
||||
return fmt.Sprintf("%s IS NOT NULL", column), nil, nil
|
||||
|
||||
case OpContains:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
value := fmt.Sprintf("%%%v%%", filter.Value)
|
||||
return fmt.Sprintf("%s ILIKE $%d", column, qb.paramCounter), []interface{}{value}, nil
|
||||
|
||||
case OpNotContains:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
value := fmt.Sprintf("%%%v%%", filter.Value)
|
||||
return fmt.Sprintf("%s NOT ILIKE $%d", column, qb.paramCounter), []interface{}{value}, nil
|
||||
|
||||
case OpStartsWith:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
value := fmt.Sprintf("%v%%", filter.Value)
|
||||
return fmt.Sprintf("%s ILIKE $%d", column, qb.paramCounter), []interface{}{value}, nil
|
||||
|
||||
case OpEndsWith:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
value := fmt.Sprintf("%%%v", filter.Value)
|
||||
return fmt.Sprintf("%s ILIKE $%d", column, qb.paramCounter), []interface{}{value}, nil
|
||||
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unsupported operator: %s", filter.Operator)
|
||||
}
|
||||
}
|
||||
|
||||
// parseArrayValue parses array values from various formats
|
||||
func (qb *QueryBuilder) parseArrayValue(value interface{}) []interface{} {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If it's already a slice
|
||||
if reflect.TypeOf(value).Kind() == reflect.Slice {
|
||||
v := reflect.ValueOf(value)
|
||||
result := make([]interface{}, v.Len())
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
result[i] = v.Index(i).Interface()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// If it's a string, try to split by comma
|
||||
if str, ok := value.(string); ok {
|
||||
if strings.Contains(str, ",") {
|
||||
parts := strings.Split(str, ",")
|
||||
result := make([]interface{}, len(parts))
|
||||
for i, part := range parts {
|
||||
result[i] = strings.TrimSpace(part)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return []interface{}{str}
|
||||
}
|
||||
|
||||
return []interface{}{value}
|
||||
}
|
||||
|
||||
// buildOrderClause builds the ORDER BY clause
|
||||
func (qb *QueryBuilder) buildOrderClause(sortFields []SortField) string {
|
||||
if len(sortFields) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var orderParts []string
|
||||
for _, sort := range sortFields {
|
||||
column := sort.Column
|
||||
|
||||
// Security check (check original field name)
|
||||
if len(qb.allowedColumns) > 0 && !qb.allowedColumns[column] {
|
||||
continue
|
||||
}
|
||||
|
||||
if mappedCol, exists := qb.columnMapping[column]; exists {
|
||||
column = mappedCol
|
||||
}
|
||||
|
||||
order := "ASC"
|
||||
if sort.Order != "" {
|
||||
order = strings.ToUpper(sort.Order)
|
||||
}
|
||||
|
||||
orderParts = append(orderParts, fmt.Sprintf(`"%s" %s`, column, order))
|
||||
}
|
||||
|
||||
if len(orderParts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return "ORDER BY " + strings.Join(orderParts, ", ")
|
||||
}
|
||||
|
||||
// buildGroupByClause builds the GROUP BY clause
|
||||
func (qb *QueryBuilder) buildGroupByClause(groupFields []string) string {
|
||||
if len(groupFields) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var groupParts []string
|
||||
for _, field := range groupFields {
|
||||
column := field
|
||||
if mappedCol, exists := qb.columnMapping[column]; exists {
|
||||
column = mappedCol
|
||||
}
|
||||
|
||||
// Security check
|
||||
if len(qb.allowedColumns) > 0 && !qb.allowedColumns[column] {
|
||||
continue
|
||||
}
|
||||
|
||||
groupParts = append(groupParts, fmt.Sprintf(`"%s"`, column))
|
||||
}
|
||||
|
||||
if len(groupParts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return "GROUP BY " + strings.Join(groupParts, ", ")
|
||||
}
|
||||
|
||||
// buildHavingClause builds the HAVING clause
|
||||
func (qb *QueryBuilder) buildHavingClause(havingGroups []FilterGroup) (string, []interface{}, error) {
|
||||
if len(havingGroups) == 0 {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
return qb.buildWhereClause(havingGroups)
|
||||
}
|
||||
|
||||
// BuildCountQuery builds a count query
|
||||
func (qb *QueryBuilder) BuildCountQuery(query DynamicQuery) (string, []interface{}, error) {
|
||||
qb.paramCounter = 0
|
||||
|
||||
// Build FROM clause
|
||||
fromClause := fmt.Sprintf("FROM %s", qb.tableName)
|
||||
|
||||
// Build WHERE clause
|
||||
whereClause, whereArgs, err := qb.buildWhereClause(query.Filters)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
// Build GROUP BY clause
|
||||
groupClause := qb.buildGroupByClause(query.GroupBy)
|
||||
|
||||
// Build HAVING clause
|
||||
havingClause, havingArgs, err := qb.buildHavingClause(query.Having)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
// Combine parts
|
||||
sqlParts := []string{"SELECT COUNT(*)", fromClause}
|
||||
args := []interface{}{}
|
||||
|
||||
if whereClause != "" {
|
||||
sqlParts = append(sqlParts, "WHERE "+whereClause)
|
||||
args = append(args, whereArgs...)
|
||||
}
|
||||
|
||||
if groupClause != "" {
|
||||
sqlParts = append(sqlParts, groupClause)
|
||||
}
|
||||
|
||||
if havingClause != "" {
|
||||
sqlParts = append(sqlParts, "HAVING "+havingClause)
|
||||
args = append(args, havingArgs...)
|
||||
}
|
||||
|
||||
sql := strings.Join(sqlParts, " ")
|
||||
return sql, args, nil
|
||||
}
|
||||
241
internal/utils/filters/query_parser.go
Normal file
241
internal/utils/filters/query_parser.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// QueryParser parses HTTP query parameters into DynamicQuery
|
||||
type QueryParser struct {
|
||||
defaultLimit int
|
||||
maxLimit int
|
||||
}
|
||||
|
||||
// NewQueryParser creates a new query parser
|
||||
func NewQueryParser() *QueryParser {
|
||||
return &QueryParser{
|
||||
defaultLimit: 10,
|
||||
maxLimit: 100,
|
||||
}
|
||||
}
|
||||
|
||||
// SetLimits sets default and maximum limits
|
||||
func (qp *QueryParser) SetLimits(defaultLimit, maxLimit int) *QueryParser {
|
||||
qp.defaultLimit = defaultLimit
|
||||
qp.maxLimit = maxLimit
|
||||
return qp
|
||||
}
|
||||
|
||||
// ParseQuery parses URL query parameters into DynamicQuery
|
||||
func (qp *QueryParser) ParseQuery(values url.Values) (DynamicQuery, error) {
|
||||
query := DynamicQuery{
|
||||
Limit: qp.defaultLimit,
|
||||
Offset: 0,
|
||||
}
|
||||
|
||||
// Parse fields
|
||||
if fields := values.Get("fields"); fields != "" {
|
||||
if fields == "*.*" || fields == "*" {
|
||||
query.Fields = []string{"*"}
|
||||
} else {
|
||||
query.Fields = strings.Split(fields, ",")
|
||||
for i, field := range query.Fields {
|
||||
query.Fields[i] = strings.TrimSpace(field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse pagination
|
||||
if limit := values.Get("limit"); limit != "" {
|
||||
if l, err := strconv.Atoi(limit); err == nil {
|
||||
if l > 0 && l <= qp.maxLimit {
|
||||
query.Limit = l
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if offset := values.Get("offset"); offset != "" {
|
||||
if o, err := strconv.Atoi(offset); err == nil && o >= 0 {
|
||||
query.Offset = o
|
||||
}
|
||||
}
|
||||
|
||||
// Parse filters
|
||||
filters, err := qp.parseFilters(values)
|
||||
if err != nil {
|
||||
return query, err
|
||||
}
|
||||
query.Filters = filters
|
||||
|
||||
// Parse sorting
|
||||
sorts, err := qp.parseSorting(values)
|
||||
if err != nil {
|
||||
return query, err
|
||||
}
|
||||
query.Sort = sorts
|
||||
|
||||
// Parse group by
|
||||
if groupBy := values.Get("group"); groupBy != "" {
|
||||
query.GroupBy = strings.Split(groupBy, ",")
|
||||
for i, field := range query.GroupBy {
|
||||
query.GroupBy[i] = strings.TrimSpace(field)
|
||||
}
|
||||
}
|
||||
|
||||
return query, nil
|
||||
}
|
||||
|
||||
// parseFilters parses filter parameters
|
||||
// Supports format: filter[column][operator]=value
|
||||
func (qp *QueryParser) parseFilters(values url.Values) ([]FilterGroup, error) {
|
||||
filterMap := make(map[string]map[string]string)
|
||||
|
||||
// Group filters by column
|
||||
for key, vals := range values {
|
||||
if strings.HasPrefix(key, "filter[") && strings.HasSuffix(key, "]") {
|
||||
// Parse filter[column][operator] format
|
||||
parts := strings.Split(key[7:len(key)-1], "][")
|
||||
if len(parts) == 2 {
|
||||
column := parts[0]
|
||||
operator := parts[1]
|
||||
|
||||
if filterMap[column] == nil {
|
||||
filterMap[column] = make(map[string]string)
|
||||
}
|
||||
|
||||
if len(vals) > 0 {
|
||||
filterMap[column][operator] = vals[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(filterMap) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Convert to FilterGroup
|
||||
var filters []DynamicFilter
|
||||
|
||||
for column, operators := range filterMap {
|
||||
for opStr, value := range operators {
|
||||
operator := FilterOperator(opStr)
|
||||
|
||||
// Parse value based on operator
|
||||
var parsedValue interface{}
|
||||
switch operator {
|
||||
case OpIn, OpNotIn:
|
||||
if value != "" {
|
||||
parsedValue = strings.Split(value, ",")
|
||||
}
|
||||
case OpBetween, OpNotBetween:
|
||||
if value != "" {
|
||||
parts := strings.Split(value, ",")
|
||||
if len(parts) == 2 {
|
||||
parsedValue = []interface{}{strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])}
|
||||
}
|
||||
}
|
||||
case OpNull, OpNotNull:
|
||||
parsedValue = nil
|
||||
default:
|
||||
parsedValue = value
|
||||
}
|
||||
|
||||
filters = append(filters, DynamicFilter{
|
||||
Column: column,
|
||||
Operator: operator,
|
||||
Value: parsedValue,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(filters) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return []FilterGroup{{
|
||||
Filters: filters,
|
||||
LogicOp: "AND",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// parseSorting parses sort parameters
|
||||
// Supports format: sort=column1,-column2 (- for DESC)
|
||||
func (qp *QueryParser) parseSorting(values url.Values) ([]SortField, error) {
|
||||
sortParam := values.Get("sort")
|
||||
if sortParam == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var sorts []SortField
|
||||
fields := strings.Split(sortParam, ",")
|
||||
|
||||
for _, field := range fields {
|
||||
field = strings.TrimSpace(field)
|
||||
if field == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
order := "ASC"
|
||||
column := field
|
||||
|
||||
if strings.HasPrefix(field, "-") {
|
||||
order = "DESC"
|
||||
column = field[1:]
|
||||
} else if strings.HasPrefix(field, "+") {
|
||||
column = field[1:]
|
||||
}
|
||||
|
||||
sorts = append(sorts, SortField{
|
||||
Column: column,
|
||||
Order: order,
|
||||
})
|
||||
}
|
||||
|
||||
return sorts, nil
|
||||
}
|
||||
|
||||
// ParseAdvancedFilters parses complex filter structures
|
||||
// Supports nested filters and logic operators
|
||||
func (qp *QueryParser) ParseAdvancedFilters(filterParam string) ([]FilterGroup, error) {
|
||||
// This would be for more complex JSON-based filters
|
||||
// Implementation depends on your specific needs
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Helper function to parse date values
|
||||
func parseDate(value string) (interface{}, error) {
|
||||
// Try different date formats
|
||||
formats := []string{
|
||||
"2006-01-02",
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02T15:04:05.000Z",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
|
||||
for _, format := range formats {
|
||||
if t, err := time.Parse(format, value); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// Helper function to parse numeric values
|
||||
func parseNumeric(value string) interface{} {
|
||||
// Try integer first
|
||||
if i, err := strconv.Atoi(value); err == nil {
|
||||
return i
|
||||
}
|
||||
|
||||
// Try float
|
||||
if f, err := strconv.ParseFloat(value, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
|
||||
// Return as string
|
||||
return value
|
||||
}
|
||||
141
internal/utils/validation/duplicate_validator.go
Normal file
141
internal/utils/validation/duplicate_validator.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ValidationConfig holds configuration for duplicate validation
|
||||
type ValidationConfig struct {
|
||||
TableName string
|
||||
IDColumn string
|
||||
StatusColumn string
|
||||
DateColumn string
|
||||
ActiveStatuses []string
|
||||
AdditionalFields map[string]interface{}
|
||||
}
|
||||
|
||||
// DuplicateValidator provides methods for validating duplicate entries
|
||||
type DuplicateValidator struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewDuplicateValidator creates a new instance of DuplicateValidator
|
||||
func NewDuplicateValidator(db *sql.DB) *DuplicateValidator {
|
||||
return &DuplicateValidator{db: db}
|
||||
}
|
||||
|
||||
// ValidateDuplicate checks for duplicate entries based on the provided configuration
|
||||
func (dv *DuplicateValidator) ValidateDuplicate(ctx context.Context, config ValidationConfig, identifier interface{}) error {
|
||||
query := fmt.Sprintf(`
|
||||
SELECT COUNT(*)
|
||||
FROM %s
|
||||
WHERE %s = $1
|
||||
AND %s = ANY($2)
|
||||
AND DATE(%s) = CURRENT_DATE
|
||||
`, config.TableName, config.IDColumn, config.StatusColumn, config.DateColumn)
|
||||
|
||||
var count int
|
||||
err := dv.db.QueryRowContext(ctx, query, identifier, config.ActiveStatuses).Scan(&count)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check duplicate: %w", err)
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
return fmt.Errorf("data with ID %v already exists with active status today", identifier)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateDuplicateWithCustomFields checks for duplicates with additional custom fields
|
||||
func (dv *DuplicateValidator) ValidateDuplicateWithCustomFields(ctx context.Context, config ValidationConfig, fields map[string]interface{}) error {
|
||||
whereClause := fmt.Sprintf("%s = ANY($1) AND DATE(%s) = CURRENT_DATE", config.StatusColumn, config.DateColumn)
|
||||
args := []interface{}{config.ActiveStatuses}
|
||||
argIndex := 2
|
||||
|
||||
// Add additional field conditions
|
||||
for fieldName, fieldValue := range config.AdditionalFields {
|
||||
whereClause += fmt.Sprintf(" AND %s = $%d", fieldName, argIndex)
|
||||
args = append(args, fieldValue)
|
||||
argIndex++
|
||||
}
|
||||
|
||||
// Add dynamic fields
|
||||
for fieldName, fieldValue := range fields {
|
||||
whereClause += fmt.Sprintf(" AND %s = $%d", fieldName, argIndex)
|
||||
args = append(args, fieldValue)
|
||||
argIndex++
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE %s", config.TableName, whereClause)
|
||||
|
||||
var count int
|
||||
err := dv.db.QueryRowContext(ctx, query, args...).Scan(&count)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check duplicate with custom fields: %w", err)
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
return fmt.Errorf("duplicate entry found with the specified criteria")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateOncePerDay ensures only one submission per day for a given identifier
|
||||
func (dv *DuplicateValidator) ValidateOncePerDay(ctx context.Context, tableName, idColumn, dateColumn string, identifier interface{}) error {
|
||||
query := fmt.Sprintf(`
|
||||
SELECT COUNT(*)
|
||||
FROM %s
|
||||
WHERE %s = $1
|
||||
AND DATE(%s) = CURRENT_DATE
|
||||
`, tableName, idColumn, dateColumn)
|
||||
|
||||
var count int
|
||||
err := dv.db.QueryRowContext(ctx, query, identifier).Scan(&count)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check daily submission: %w", err)
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
return fmt.Errorf("only one submission allowed per day for ID %v", identifier)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLastSubmissionTime returns the last submission time for a given identifier
|
||||
func (dv *DuplicateValidator) GetLastSubmissionTime(ctx context.Context, tableName, idColumn, dateColumn string, identifier interface{}) (*time.Time, error) {
|
||||
query := fmt.Sprintf(`
|
||||
SELECT %s
|
||||
FROM %s
|
||||
WHERE %s = $1
|
||||
ORDER BY %s DESC
|
||||
LIMIT 1
|
||||
`, dateColumn, tableName, idColumn, dateColumn)
|
||||
|
||||
var lastTime time.Time
|
||||
err := dv.db.QueryRowContext(ctx, query, identifier).Scan(&lastTime)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil // No previous submission
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get last submission time: %w", err)
|
||||
}
|
||||
|
||||
return &lastTime, nil
|
||||
}
|
||||
|
||||
// DefaultRetribusiConfig returns default configuration for retribusi validation
|
||||
func DefaultRetribusiConfig() ValidationConfig {
|
||||
return ValidationConfig{
|
||||
TableName: "data_retribusi",
|
||||
IDColumn: "id",
|
||||
StatusColumn: "status",
|
||||
DateColumn: "date_created",
|
||||
ActiveStatuses: []string{"active", "draft"},
|
||||
}
|
||||
}
|
||||
356
pkg/logger/README.md
Normal file
356
pkg/logger/README.md
Normal file
@@ -0,0 +1,356 @@
|
||||
# Structured Logger Package
|
||||
|
||||
A comprehensive structured logging package for Go applications with support for different log levels, service-specific logging, request context, and JSON output formatting.
|
||||
|
||||
## Features
|
||||
|
||||
- **Structured Logging**: JSON and text format output with rich metadata
|
||||
- **Multiple Log Levels**: DEBUG, INFO, WARN, ERROR, FATAL
|
||||
- **Service-Specific Logging**: Dedicated loggers for different services
|
||||
- **Request Context**: Request ID and correlation ID tracking
|
||||
- **Performance Timing**: Built-in duration logging for operations
|
||||
- **Gin Middleware**: Request logging middleware for HTTP requests
|
||||
- **Environment Configuration**: Configurable via environment variables
|
||||
|
||||
## Installation
|
||||
|
||||
The logger is already integrated into the project. Import it using:
|
||||
|
||||
```go
|
||||
import "api-service/pkg/logger"
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```go
|
||||
// Global functions (use default logger)
|
||||
logger.Info("Application starting")
|
||||
logger.Error("Something went wrong", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"code": "DB_CONNECTION_FAILED",
|
||||
})
|
||||
|
||||
// Create a service-specific logger
|
||||
authLogger := logger.ServiceLogger("auth-service")
|
||||
authLogger.Info("User authenticated", map[string]interface{}{
|
||||
"user_id": "123",
|
||||
"method": "oauth2",
|
||||
})
|
||||
```
|
||||
|
||||
### Service-Specific Loggers
|
||||
|
||||
```go
|
||||
// Pre-defined service loggers
|
||||
authLogger := logger.AuthServiceLogger()
|
||||
bpjsLogger := logger.BPJSServiceLogger()
|
||||
retribusiLogger := logger.RetribusiServiceLogger()
|
||||
databaseLogger := logger.DatabaseServiceLogger()
|
||||
|
||||
authLogger.Info("Authentication successful")
|
||||
databaseLogger.Debug("Query executed", map[string]interface{}{
|
||||
"query": "SELECT * FROM users",
|
||||
"time": "150ms",
|
||||
})
|
||||
```
|
||||
|
||||
### Request Context Logging
|
||||
|
||||
```go
|
||||
// Add request context to logs
|
||||
requestLogger := logger.Default().
|
||||
WithRequestID("req-123456").
|
||||
WithCorrelationID("corr-789012").
|
||||
WithField("user_id", "user-123")
|
||||
|
||||
requestLogger.Info("Request processing started", map[string]interface{}{
|
||||
"endpoint": "/api/v1/data",
|
||||
"method": "POST",
|
||||
})
|
||||
```
|
||||
|
||||
### Performance Timing
|
||||
|
||||
```go
|
||||
// Time operations and log duration
|
||||
start := time.Now()
|
||||
// ... perform operation ...
|
||||
logger.LogDuration(start, "Database query completed", map[string]interface{}{
|
||||
"query": "SELECT * FROM large_table",
|
||||
"rows": 1000,
|
||||
"database": "postgres",
|
||||
})
|
||||
```
|
||||
|
||||
## Gin Middleware Integration
|
||||
|
||||
### Add Request Logger Middleware
|
||||
|
||||
In your routes setup:
|
||||
|
||||
```go
|
||||
import "api-service/pkg/logger"
|
||||
|
||||
func RegisterRoutes(cfg *config.Config) *gin.Engine {
|
||||
router := gin.New()
|
||||
|
||||
// Add request logging middleware
|
||||
router.Use(logger.RequestLoggerMiddleware(logger.Default()))
|
||||
|
||||
// ... other middleware and routes
|
||||
return router
|
||||
}
|
||||
```
|
||||
|
||||
### Access Logger in Handlers
|
||||
|
||||
```go
|
||||
func (h *MyHandler) MyEndpoint(c *gin.Context) {
|
||||
// Get logger from context
|
||||
logger := logger.GetLoggerFromContext(c)
|
||||
|
||||
logger.Info("Endpoint called", map[string]interface{}{
|
||||
"user_agent": c.Request.UserAgent(),
|
||||
"client_ip": c.ClientIP(),
|
||||
})
|
||||
|
||||
// Get request IDs
|
||||
requestID := logger.GetRequestIDFromContext(c)
|
||||
correlationID := logger.GetCorrelationIDFromContext(c)
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Set these environment variables to configure the logger:
|
||||
|
||||
```bash
|
||||
# Log level (DEBUG, INFO, WARN, ERROR, FATAL)
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Output format (text or json)
|
||||
LOG_FORMAT=text
|
||||
|
||||
# Service name for logs
|
||||
LOG_SERVICE=api-service
|
||||
|
||||
# Enable JSON format
|
||||
LOG_JSON=false
|
||||
```
|
||||
|
||||
### Programmatic Configuration
|
||||
|
||||
```go
|
||||
// Create custom logger with specific configuration
|
||||
cfg := logger.Config{
|
||||
Level: "DEBUG",
|
||||
JSONFormat: true,
|
||||
Service: "my-custom-service",
|
||||
}
|
||||
|
||||
customLogger := logger.NewFromConfig(cfg)
|
||||
|
||||
// Or create manually
|
||||
logger := logger.New("service-name", logger.DEBUG, true)
|
||||
```
|
||||
|
||||
## Log Levels
|
||||
|
||||
| Level | Description | Usage |
|
||||
|-------|-------------|-------|
|
||||
| DEBUG | Detailed debug information | Development and troubleshooting |
|
||||
| INFO | General operational messages | Normal application behavior |
|
||||
| WARN | Warning conditions | Something unexpected but not an error |
|
||||
| ERROR | Error conditions | Operation failed but application continues |
|
||||
| FATAL | Critical conditions | Application cannot continue |
|
||||
|
||||
## Output Formats
|
||||
|
||||
### Text Format (Default)
|
||||
```
|
||||
2025-08-22T04:33:12+07:00 [INFO] auth-service: User authentication successful (handler/auth.go:45) [user_id=12345 method=oauth2]
|
||||
```
|
||||
|
||||
### JSON Format
|
||||
```json
|
||||
{
|
||||
"timestamp": "2025-08-22T04:33:12+07:00",
|
||||
"level": "INFO",
|
||||
"service": "auth-service",
|
||||
"message": "User authentication successful",
|
||||
"file": "handler/auth.go",
|
||||
"line": 45,
|
||||
"request_id": "req-123456",
|
||||
"correlation_id": "corr-789012",
|
||||
"fields": {
|
||||
"user_id": "12345",
|
||||
"method": "oauth2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Appropriate Log Levels
|
||||
```go
|
||||
// Good
|
||||
logger.Debug("Detailed debug info")
|
||||
logger.Info("User action completed")
|
||||
logger.Warn("Rate limit approaching")
|
||||
logger.Error("Database connection failed")
|
||||
|
||||
// Avoid
|
||||
logger.Info("Error connecting to database") // Use ERROR instead
|
||||
```
|
||||
|
||||
### 2. Add Context to Logs
|
||||
```go
|
||||
// Instead of this:
|
||||
logger.Error("Login failed")
|
||||
|
||||
// Do this:
|
||||
logger.Error("Login failed", map[string]interface{}{
|
||||
"username": username,
|
||||
"reason": "invalid_credentials",
|
||||
"attempts": loginAttempts,
|
||||
"client_ip": clientIP,
|
||||
})
|
||||
```
|
||||
|
||||
### 3. Use Service-Specific Loggers
|
||||
```go
|
||||
// Create once per service
|
||||
var authLogger = logger.AuthServiceLogger()
|
||||
|
||||
func LoginHandler(c *gin.Context) {
|
||||
authLogger.Info("Login attempt", map[string]interface{}{
|
||||
"username": c.PostForm("username"),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Measure Performance
|
||||
```go
|
||||
func ProcessData(data []byte) error {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
logger.LogDuration(start, "Data processing completed", map[string]interface{}{
|
||||
"data_size": len(data),
|
||||
"items": countItems(data),
|
||||
})
|
||||
}()
|
||||
|
||||
// ... processing logic ...
|
||||
}
|
||||
```
|
||||
|
||||
## Migration from Standard Log Package
|
||||
|
||||
### Before (standard log)
|
||||
```go
|
||||
import "log"
|
||||
|
||||
log.Printf("Error: %v", err)
|
||||
log.Printf("User %s logged in", username)
|
||||
```
|
||||
|
||||
### After (structured logger)
|
||||
```go
|
||||
import "api-service/pkg/logger"
|
||||
|
||||
logger.Error("Operation failed", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"context": "user_login",
|
||||
})
|
||||
|
||||
logger.Info("User logged in", map[string]interface{}{
|
||||
"username": username,
|
||||
"method": "password",
|
||||
})
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Database Operations
|
||||
```go
|
||||
func (h *UserHandler) GetUser(c *gin.Context) {
|
||||
logger := logger.GetLoggerFromContext(c)
|
||||
start := time.Now()
|
||||
|
||||
user, err := h.db.GetUser(c.Param("id"))
|
||||
if err != nil {
|
||||
logger.Error("Failed to get user", map[string]interface{}{
|
||||
"user_id": c.Param("id"),
|
||||
"error": err.Error(),
|
||||
})
|
||||
c.JSON(500, gin.H{"error": "Internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
logger.LogDuration(start, "User retrieved successfully", map[string]interface{}{
|
||||
"user_id": user.ID,
|
||||
"query_time": time.Since(start).String(),
|
||||
})
|
||||
|
||||
c.JSON(200, user)
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication Service
|
||||
```go
|
||||
var authLogger = logger.AuthServiceLogger()
|
||||
|
||||
func Authenticate(username, password string) (bool, error) {
|
||||
authLogger.Debug("Authentication attempt", map[string]interface{}{
|
||||
"username": username,
|
||||
})
|
||||
|
||||
// Authentication logic...
|
||||
|
||||
if authenticated {
|
||||
authLogger.Info("Authentication successful", map[string]interface{}{
|
||||
"username": username,
|
||||
"method": "password",
|
||||
})
|
||||
return true, nil
|
||||
}
|
||||
|
||||
authLogger.Warn("Authentication failed", map[string]interface{}{
|
||||
"username": username,
|
||||
"reason": "invalid_credentials",
|
||||
})
|
||||
return false, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **No logs appearing**: Check that log level is not set too high (e.g., ERROR when logging INFO)
|
||||
2. **JSON format not working**: Ensure `LOG_JSON=true` or logger is created with `jsonFormat: true`
|
||||
3. **Missing context**: Use `WithRequestID()` and `WithCorrelationID()` for request context
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging for development:
|
||||
|
||||
```bash
|
||||
export LOG_LEVEL=DEBUG
|
||||
export LOG_FORMAT=text
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Logger is designed to be lightweight and fast
|
||||
- Context fields are only evaluated when the log level is enabled
|
||||
- JSON marshaling only occurs when JSON format is enabled
|
||||
- Consider log volume in production environments
|
||||
|
||||
## License
|
||||
|
||||
This logger package is part of the API Service project.
|
||||
137
pkg/logger/config.go
Normal file
137
pkg/logger/config.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds the configuration for the logger
|
||||
type Config struct {
|
||||
Level string `json:"level" default:"INFO"`
|
||||
JSONFormat bool `json:"json_format" default:"false"`
|
||||
Service string `json:"service" default:"api-service"`
|
||||
}
|
||||
|
||||
// DefaultConfig returns the default logger configuration
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Level: "INFO",
|
||||
JSONFormat: false,
|
||||
Service: "api-service",
|
||||
}
|
||||
}
|
||||
|
||||
// LoadConfigFromEnv loads logger configuration from environment variables
|
||||
func LoadConfigFromEnv() Config {
|
||||
config := DefaultConfig()
|
||||
|
||||
// Load log level from environment
|
||||
if level := os.Getenv("LOG_LEVEL"); level != "" {
|
||||
config.Level = strings.ToUpper(level)
|
||||
}
|
||||
|
||||
// Load JSON format from environment
|
||||
if jsonFormat := os.Getenv("LOG_JSON_FORMAT"); jsonFormat != "" {
|
||||
if parsed, err := strconv.ParseBool(jsonFormat); err == nil {
|
||||
config.JSONFormat = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Load service name from environment
|
||||
if service := os.Getenv("LOG_SERVICE_NAME"); service != "" {
|
||||
config.Service = service
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// Validate validates the logger configuration
|
||||
func (c *Config) Validate() error {
|
||||
// Validate log level
|
||||
validLevels := map[string]bool{
|
||||
"DEBUG": true,
|
||||
"INFO": true,
|
||||
"WARN": true,
|
||||
"ERROR": true,
|
||||
"FATAL": true,
|
||||
}
|
||||
|
||||
if !validLevels[c.Level] {
|
||||
c.Level = "INFO" // Default to INFO if invalid
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLogLevel returns the LogLevel from the configuration
|
||||
func (c *Config) GetLogLevel() LogLevel {
|
||||
switch strings.ToUpper(c.Level) {
|
||||
case "DEBUG":
|
||||
return DEBUG
|
||||
case "WARN":
|
||||
return WARN
|
||||
case "ERROR":
|
||||
return ERROR
|
||||
case "FATAL":
|
||||
return FATAL
|
||||
default:
|
||||
return INFO
|
||||
}
|
||||
}
|
||||
|
||||
// CreateLoggerFromConfig creates a new logger instance from configuration
|
||||
func CreateLoggerFromConfig(cfg Config) *Logger {
|
||||
cfg.Validate()
|
||||
return NewFromConfig(cfg)
|
||||
}
|
||||
|
||||
// CreateLoggerFromEnv creates a new logger instance from environment variables
|
||||
func CreateLoggerFromEnv() *Logger {
|
||||
cfg := LoadConfigFromEnv()
|
||||
return CreateLoggerFromConfig(cfg)
|
||||
}
|
||||
|
||||
// Environment variable constants
|
||||
const (
|
||||
EnvLogLevel = "LOG_LEVEL"
|
||||
EnvLogJSONFormat = "LOG_JSON_FORMAT"
|
||||
EnvLogService = "LOG_SERVICE_NAME"
|
||||
)
|
||||
|
||||
// Service-specific configuration helpers
|
||||
|
||||
// AuthServiceConfig returns configuration for auth service
|
||||
func AuthServiceConfig() Config {
|
||||
cfg := LoadConfigFromEnv()
|
||||
cfg.Service = "auth-service"
|
||||
return cfg
|
||||
}
|
||||
|
||||
// BPJSServiceConfig returns configuration for BPJS service
|
||||
func BPJSServiceConfig() Config {
|
||||
cfg := LoadConfigFromEnv()
|
||||
cfg.Service = "bpjs-service"
|
||||
return cfg
|
||||
}
|
||||
|
||||
// RetribusiServiceConfig returns configuration for retribusi service
|
||||
func RetribusiServiceConfig() Config {
|
||||
cfg := LoadConfigFromEnv()
|
||||
cfg.Service = "retribusi-service"
|
||||
return cfg
|
||||
}
|
||||
|
||||
// DatabaseServiceConfig returns configuration for database service
|
||||
func DatabaseServiceConfig() Config {
|
||||
cfg := LoadConfigFromEnv()
|
||||
cfg.Service = "database-service"
|
||||
return cfg
|
||||
}
|
||||
|
||||
// MiddlewareServiceConfig returns configuration for middleware service
|
||||
func MiddlewareServiceConfig() Config {
|
||||
cfg := LoadConfigFromEnv()
|
||||
cfg.Service = "middleware-service"
|
||||
return cfg
|
||||
}
|
||||
142
pkg/logger/context.go
Normal file
142
pkg/logger/context.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// contextKey is a custom type for context keys to avoid collisions
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
loggerKey contextKey = "logger"
|
||||
requestIDKey contextKey = "request_id"
|
||||
correlationIDKey contextKey = "correlation_id"
|
||||
serviceNameKey contextKey = "service_name"
|
||||
)
|
||||
|
||||
// ContextWithLogger creates a new context with the logger
|
||||
func ContextWithLogger(ctx context.Context, logger *Logger) context.Context {
|
||||
return context.WithValue(ctx, loggerKey, logger)
|
||||
}
|
||||
|
||||
// LoggerFromContext retrieves the logger from context
|
||||
func LoggerFromContext(ctx context.Context) *Logger {
|
||||
if logger, ok := ctx.Value(loggerKey).(*Logger); ok {
|
||||
return logger
|
||||
}
|
||||
return globalLogger
|
||||
}
|
||||
|
||||
// ContextWithRequestID creates a new context with the request ID
|
||||
func ContextWithRequestID(ctx context.Context, requestID string) context.Context {
|
||||
return context.WithValue(ctx, requestIDKey, requestID)
|
||||
}
|
||||
|
||||
// RequestIDFromContext retrieves the request ID from context
|
||||
func RequestIDFromContext(ctx context.Context) string {
|
||||
if requestID, ok := ctx.Value(requestIDKey).(string); ok {
|
||||
return requestID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ContextWithCorrelationID creates a new context with the correlation ID
|
||||
func ContextWithCorrelationID(ctx context.Context, correlationID string) context.Context {
|
||||
return context.WithValue(ctx, correlationIDKey, correlationID)
|
||||
}
|
||||
|
||||
// CorrelationIDFromContext retrieves the correlation ID from context
|
||||
func CorrelationIDFromContext(ctx context.Context) string {
|
||||
if correlationID, ok := ctx.Value(correlationIDKey).(string); ok {
|
||||
return correlationID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ContextWithServiceName creates a new context with the service name
|
||||
func ContextWithServiceName(ctx context.Context, serviceName string) context.Context {
|
||||
return context.WithValue(ctx, serviceNameKey, serviceName)
|
||||
}
|
||||
|
||||
// ServiceNameFromContext retrieves the service name from context
|
||||
func ServiceNameFromContext(ctx context.Context) string {
|
||||
if serviceName, ok := ctx.Value(serviceNameKey).(string); ok {
|
||||
return serviceName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WithContext returns a new logger with context values
|
||||
func (l *Logger) WithContext(ctx context.Context) *Logger {
|
||||
logger := l
|
||||
|
||||
if requestID := RequestIDFromContext(ctx); requestID != "" {
|
||||
logger = logger.WithRequestID(requestID)
|
||||
}
|
||||
|
||||
if correlationID := CorrelationIDFromContext(ctx); correlationID != "" {
|
||||
logger = logger.WithCorrelationID(correlationID)
|
||||
}
|
||||
|
||||
if serviceName := ServiceNameFromContext(ctx); serviceName != "" {
|
||||
logger = logger.WithService(serviceName)
|
||||
}
|
||||
|
||||
return logger
|
||||
}
|
||||
|
||||
// DebugCtx logs a debug message with context
|
||||
func DebugCtx(ctx context.Context, msg string, fields ...map[string]interface{}) {
|
||||
LoggerFromContext(ctx).WithContext(ctx).Debug(msg, fields...)
|
||||
}
|
||||
|
||||
// DebugfCtx logs a formatted debug message with context
|
||||
func DebugfCtx(ctx context.Context, format string, args ...interface{}) {
|
||||
LoggerFromContext(ctx).WithContext(ctx).Debugf(format, args...)
|
||||
}
|
||||
|
||||
// InfoCtx logs an info message with context
|
||||
func InfoCtx(ctx context.Context, msg string, fields ...map[string]interface{}) {
|
||||
LoggerFromContext(ctx).WithContext(ctx).Info(msg, fields...)
|
||||
}
|
||||
|
||||
// InfofCtx logs a formatted info message with context
|
||||
func InfofCtx(ctx context.Context, format string, args ...interface{}) {
|
||||
LoggerFromContext(ctx).WithContext(ctx).Infof(format, args...)
|
||||
}
|
||||
|
||||
// WarnCtx logs a warning message with context
|
||||
func WarnCtx(ctx context.Context, msg string, fields ...map[string]interface{}) {
|
||||
LoggerFromContext(ctx).WithContext(ctx).Warn(msg, fields...)
|
||||
}
|
||||
|
||||
// WarnfCtx logs a formatted warning message with context
|
||||
func WarnfCtx(ctx context.Context, format string, args ...interface{}) {
|
||||
LoggerFromContext(ctx).WithContext(ctx).Warnf(format, args...)
|
||||
}
|
||||
|
||||
// ErrorCtx logs an error message with context
|
||||
func ErrorCtx(ctx context.Context, msg string, fields ...map[string]interface{}) {
|
||||
LoggerFromContext(ctx).WithContext(ctx).Error(msg, fields...)
|
||||
}
|
||||
|
||||
// ErrorfCtx logs a formatted error message with context
|
||||
func ErrorfCtx(ctx context.Context, format string, args ...interface{}) {
|
||||
LoggerFromContext(ctx).WithContext(ctx).Errorf(format, args...)
|
||||
}
|
||||
|
||||
// FatalCtx logs a fatal message with context and exits the program
|
||||
func FatalCtx(ctx context.Context, msg string, fields ...map[string]interface{}) {
|
||||
LoggerFromContext(ctx).WithContext(ctx).Fatal(msg, fields...)
|
||||
}
|
||||
|
||||
// FatalfCtx logs a formatted fatal message with context and exits the program
|
||||
func FatalfCtx(ctx context.Context, format string, args ...interface{}) {
|
||||
LoggerFromContext(ctx).WithContext(ctx).Fatalf(format, args...)
|
||||
}
|
||||
|
||||
// LogDurationCtx logs the duration of an operation with context
|
||||
func LogDurationCtx(ctx context.Context, start time.Time, operation string, fields ...map[string]interface{}) {
|
||||
LoggerFromContext(ctx).WithContext(ctx).LogDuration(start, operation, fields...)
|
||||
}
|
||||
616
pkg/logger/logger.go
Normal file
616
pkg/logger/logger.go
Normal file
@@ -0,0 +1,616 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LogLevel represents the severity level of a log message
|
||||
type LogLevel int
|
||||
|
||||
const (
|
||||
DEBUG LogLevel = iota
|
||||
INFO
|
||||
WARN
|
||||
ERROR
|
||||
FATAL
|
||||
)
|
||||
|
||||
var (
|
||||
levelStrings = map[LogLevel]string{
|
||||
DEBUG: "DEBUG",
|
||||
INFO: "INFO",
|
||||
WARN: "WARN",
|
||||
ERROR: "ERROR",
|
||||
FATAL: "FATAL",
|
||||
}
|
||||
|
||||
stringLevels = map[string]LogLevel{
|
||||
"DEBUG": DEBUG,
|
||||
"INFO": INFO,
|
||||
"WARN": WARN,
|
||||
"ERROR": ERROR,
|
||||
"FATAL": FATAL,
|
||||
}
|
||||
)
|
||||
|
||||
// Logger represents a structured logger instance
|
||||
type Logger struct {
|
||||
serviceName string
|
||||
level LogLevel
|
||||
output *log.Logger
|
||||
mu sync.Mutex
|
||||
jsonFormat bool
|
||||
|
||||
logDir string
|
||||
}
|
||||
|
||||
// LogEntry represents a structured log entry
|
||||
type LogEntry struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Service string `json:"service"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
File string `json:"file,omitempty"`
|
||||
Line int `json:"line,omitempty"`
|
||||
Duration string `json:"duration,omitempty"`
|
||||
Fields map[string]interface{} `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
// New creates a new logger instance
|
||||
func New(serviceName string, level LogLevel, jsonFormat bool, logDir ...string) *Logger {
|
||||
// Tentukan direktori log berdasarkan prioritas:
|
||||
// 1. Parameter logDir (jika disediakan)
|
||||
// 2. Environment variable LOG_DIR (jika ada)
|
||||
// 3. Default ke pkg/logger/data relatif terhadap root proyek
|
||||
|
||||
var finalLogDir string
|
||||
|
||||
// Cek apakah logDir disediakan sebagai parameter
|
||||
if len(logDir) > 0 && logDir[0] != "" {
|
||||
finalLogDir = logDir[0]
|
||||
} else {
|
||||
// Cek environment variable
|
||||
if envLogDir := os.Getenv("LOG_DIR"); envLogDir != "" {
|
||||
finalLogDir = envLogDir
|
||||
} else {
|
||||
// Default: dapatkan path relatif terhadap root proyek
|
||||
// Dapatkan path executable
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
// Fallback ke current working directory jika gagal
|
||||
finalLogDir = filepath.Join(".", "pkg", "logger", "data")
|
||||
} else {
|
||||
// Dapatkan direktori executable
|
||||
exeDir := filepath.Dir(exePath)
|
||||
|
||||
// Jika berjalan dengan go run, executable ada di temp directory
|
||||
// Coba dapatkan path source code
|
||||
if strings.Contains(exeDir, "go-build") || strings.Contains(exeDir, "tmp") {
|
||||
// Gunakan runtime.Caller untuk mendapatkan path source
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if ok {
|
||||
// Dapatkan direktori source (2 level up dari pkg/logger)
|
||||
sourceDir := filepath.Dir(file)
|
||||
for i := 0; i < 3; i++ { // Naik 3 level ke root proyek
|
||||
sourceDir = filepath.Dir(sourceDir)
|
||||
}
|
||||
finalLogDir = filepath.Join(sourceDir, "pkg", "logger", "data")
|
||||
} else {
|
||||
// Fallback
|
||||
finalLogDir = filepath.Join(".", "pkg", "logger", "data")
|
||||
}
|
||||
} else {
|
||||
// Untuk binary yang sudah dikompilasi, asumsikan struktur proyek
|
||||
finalLogDir = filepath.Join(exeDir, "pkg", "logger", "data")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Konversi ke path absolut
|
||||
absPath, err := filepath.Abs(finalLogDir)
|
||||
if err == nil {
|
||||
finalLogDir = absPath
|
||||
}
|
||||
|
||||
// Buat direktori jika belum ada
|
||||
if err := os.MkdirAll(finalLogDir, 0755); err != nil {
|
||||
// Fallback ke stdout jika gagal membuat direktori
|
||||
fmt.Printf("Warning: Failed to create log directory %s: %v\n", finalLogDir, err)
|
||||
return &Logger{
|
||||
serviceName: serviceName,
|
||||
level: level,
|
||||
output: log.New(os.Stdout, "", 0),
|
||||
jsonFormat: jsonFormat,
|
||||
logDir: "", // Kosongkan karena gagal
|
||||
}
|
||||
}
|
||||
|
||||
return &Logger{
|
||||
serviceName: serviceName,
|
||||
level: level,
|
||||
output: log.New(os.Stdout, "", 0),
|
||||
jsonFormat: jsonFormat,
|
||||
logDir: finalLogDir,
|
||||
}
|
||||
}
|
||||
|
||||
// NewFromConfig creates a new logger from configuration
|
||||
func NewFromConfig(cfg Config) *Logger {
|
||||
level := INFO
|
||||
if l, exists := stringLevels[strings.ToUpper(cfg.Level)]; exists {
|
||||
level = l
|
||||
}
|
||||
|
||||
return New(cfg.Service, level, cfg.JSONFormat)
|
||||
}
|
||||
|
||||
// Default creates a default logger instance
|
||||
func Default() *Logger {
|
||||
return New("api-service", INFO, false)
|
||||
}
|
||||
|
||||
// WithService returns a new logger with the specified service name
|
||||
func (l *Logger) WithService(serviceName string) *Logger {
|
||||
return &Logger{
|
||||
serviceName: serviceName,
|
||||
level: l.level,
|
||||
output: l.output,
|
||||
jsonFormat: l.jsonFormat,
|
||||
logDir: l.logDir,
|
||||
}
|
||||
}
|
||||
|
||||
// SetLevel sets the log level for the logger
|
||||
func (l *Logger) SetLevel(level LogLevel) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.level = level
|
||||
}
|
||||
|
||||
// SetJSONFormat sets whether to output logs in JSON format
|
||||
func (l *Logger) SetJSONFormat(jsonFormat bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.jsonFormat = jsonFormat
|
||||
}
|
||||
|
||||
// Debug logs a debug message
|
||||
func (l *Logger) Debug(msg string, fields ...map[string]interface{}) {
|
||||
l.log(DEBUG, msg, nil, fields...)
|
||||
}
|
||||
|
||||
// Debugf logs a formatted debug message
|
||||
func (l *Logger) Debugf(format string, args ...interface{}) {
|
||||
l.log(DEBUG, fmt.Sprintf(format, args...), nil)
|
||||
}
|
||||
|
||||
// Info logs an info message
|
||||
func (l *Logger) Info(msg string, fields ...map[string]interface{}) {
|
||||
l.log(INFO, msg, nil, fields...)
|
||||
}
|
||||
|
||||
// Infof logs a formatted info message
|
||||
func (l *Logger) Infof(format string, args ...interface{}) {
|
||||
l.log(INFO, fmt.Sprintf(format, args...), nil)
|
||||
}
|
||||
|
||||
// Warn logs a warning message
|
||||
func (l *Logger) Warn(msg string, fields ...map[string]interface{}) {
|
||||
l.log(WARN, msg, nil, fields...)
|
||||
}
|
||||
|
||||
// Warnf logs a formatted warning message
|
||||
func (l *Logger) Warnf(format string, args ...interface{}) {
|
||||
l.log(WARN, fmt.Sprintf(format, args...), nil)
|
||||
}
|
||||
|
||||
// Error logs an error message
|
||||
func (l *Logger) Error(msg string, fields ...map[string]interface{}) {
|
||||
l.log(ERROR, msg, nil, fields...)
|
||||
}
|
||||
|
||||
// Errorf logs a formatted error message
|
||||
func (l *Logger) Errorf(format string, args ...interface{}) {
|
||||
l.log(ERROR, fmt.Sprintf(format, args...), nil)
|
||||
}
|
||||
|
||||
// Fatal logs a fatal message and exits the program
|
||||
func (l *Logger) Fatal(msg string, fields ...map[string]interface{}) {
|
||||
l.log(FATAL, msg, nil, fields...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Fatalf logs a formatted fatal message and exits the program
|
||||
func (l *Logger) Fatalf(format string, args ...interface{}) {
|
||||
l.log(FATAL, fmt.Sprintf(format, args...), nil)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// WithRequestID returns a new logger with the specified request ID
|
||||
func (l *Logger) WithRequestID(requestID string) *Logger {
|
||||
return l.withField("request_id", requestID)
|
||||
}
|
||||
|
||||
// WithCorrelationID returns a new logger with the specified correlation ID
|
||||
func (l *Logger) WithCorrelationID(correlationID string) *Logger {
|
||||
return l.withField("correlation_id", correlationID)
|
||||
}
|
||||
|
||||
// WithField returns a new logger with an additional field
|
||||
func (l *Logger) WithField(key string, value interface{}) *Logger {
|
||||
return l.withField(key, value)
|
||||
}
|
||||
|
||||
// WithFields returns a new logger with additional fields
|
||||
func (l *Logger) WithFields(fields map[string]interface{}) *Logger {
|
||||
return &Logger{
|
||||
serviceName: l.serviceName,
|
||||
level: l.level,
|
||||
output: l.output,
|
||||
jsonFormat: l.jsonFormat,
|
||||
logDir: l.logDir,
|
||||
}
|
||||
}
|
||||
|
||||
// LogDuration logs the duration of an operation
|
||||
func (l *Logger) LogDuration(start time.Time, operation string, fields ...map[string]interface{}) {
|
||||
duration := time.Since(start)
|
||||
l.Info(fmt.Sprintf("%s completed", operation), append(fields, map[string]interface{}{
|
||||
"duration": duration.String(),
|
||||
"duration_ms": duration.Milliseconds(),
|
||||
})...)
|
||||
}
|
||||
|
||||
// log is the internal logging method
|
||||
func (l *Logger) log(level LogLevel, msg string, duration *time.Duration, fields ...map[string]interface{}) {
|
||||
if level < l.level {
|
||||
return
|
||||
}
|
||||
|
||||
// Get caller information
|
||||
_, file, line, ok := runtime.Caller(3) // Adjust caller depth
|
||||
var callerFile string
|
||||
var callerLine int
|
||||
if ok {
|
||||
// Shorten file path
|
||||
parts := strings.Split(file, "/")
|
||||
if len(parts) > 2 {
|
||||
callerFile = strings.Join(parts[len(parts)-2:], "/")
|
||||
} else {
|
||||
callerFile = file
|
||||
}
|
||||
callerLine = line
|
||||
}
|
||||
|
||||
// Merge all fields
|
||||
mergedFields := make(map[string]interface{})
|
||||
for _, f := range fields {
|
||||
for k, v := range f {
|
||||
mergedFields[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
entry := LogEntry{
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
Level: levelStrings[level],
|
||||
Service: l.serviceName,
|
||||
Message: msg,
|
||||
File: callerFile,
|
||||
Line: callerLine,
|
||||
Fields: mergedFields,
|
||||
}
|
||||
|
||||
if duration != nil {
|
||||
entry.Duration = duration.String()
|
||||
}
|
||||
|
||||
if l.jsonFormat {
|
||||
l.outputJSON(entry)
|
||||
} else {
|
||||
l.outputText(entry)
|
||||
}
|
||||
|
||||
if level == FATAL {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// outputJSON outputs the log entry in JSON format
|
||||
func (l *Logger) outputJSON(entry LogEntry) {
|
||||
jsonData, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
// Fallback to text output if JSON marshaling fails
|
||||
l.outputText(entry)
|
||||
return
|
||||
}
|
||||
l.output.Println(string(jsonData))
|
||||
}
|
||||
|
||||
// outputText outputs the log entry in text format
|
||||
func (l *Logger) outputText(entry LogEntry) {
|
||||
timestamp := entry.Timestamp
|
||||
level := entry.Level
|
||||
service := entry.Service
|
||||
message := entry.Message
|
||||
|
||||
// Base log line
|
||||
logLine := fmt.Sprintf("%s [%s] %s: %s", timestamp, level, service, message)
|
||||
|
||||
// Add file and line if available
|
||||
if entry.File != "" && entry.Line > 0 {
|
||||
logLine += fmt.Sprintf(" (%s:%d)", entry.File, entry.Line)
|
||||
}
|
||||
|
||||
// Add request ID if available
|
||||
if entry.RequestID != "" {
|
||||
logLine += fmt.Sprintf(" [req:%s]", entry.RequestID)
|
||||
}
|
||||
|
||||
// Add correlation ID if available
|
||||
if entry.CorrelationID != "" {
|
||||
logLine += fmt.Sprintf(" [corr:%s]", entry.CorrelationID)
|
||||
}
|
||||
|
||||
// Add duration if available
|
||||
if entry.Duration != "" {
|
||||
logLine += fmt.Sprintf(" [dur:%s]", entry.Duration)
|
||||
}
|
||||
|
||||
// Add additional fields
|
||||
if len(entry.Fields) > 0 {
|
||||
fields := make([]string, 0, len(entry.Fields))
|
||||
for k, v := range entry.Fields {
|
||||
fields = append(fields, fmt.Sprintf("%s=%v", k, v))
|
||||
}
|
||||
logLine += " [" + strings.Join(fields, " ") + "]"
|
||||
}
|
||||
|
||||
l.output.Println(logLine)
|
||||
}
|
||||
|
||||
// withField creates a new logger with an additional field
|
||||
func (l *Logger) withField(key string, value interface{}) *Logger {
|
||||
return &Logger{
|
||||
serviceName: l.serviceName,
|
||||
level: l.level,
|
||||
output: l.output,
|
||||
jsonFormat: l.jsonFormat,
|
||||
logDir: l.logDir,
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the string representation of a log level
|
||||
func (l LogLevel) String() string {
|
||||
return levelStrings[l]
|
||||
}
|
||||
|
||||
// ParseLevel parses a string into a LogLevel
|
||||
func ParseLevel(level string) (LogLevel, error) {
|
||||
if l, exists := stringLevels[strings.ToUpper(level)]; exists {
|
||||
return l, nil
|
||||
}
|
||||
return INFO, fmt.Errorf("invalid log level: %s", level)
|
||||
}
|
||||
|
||||
// Global logger instance
|
||||
var globalLogger = Default()
|
||||
|
||||
// SetGlobalLogger sets the global logger instance
|
||||
func SetGlobalLogger(logger *Logger) {
|
||||
globalLogger = logger
|
||||
}
|
||||
|
||||
// Global logging functions
|
||||
func Debug(msg string, fields ...map[string]interface{}) {
|
||||
globalLogger.Debug(msg, fields...)
|
||||
}
|
||||
|
||||
func Debugf(format string, args ...interface{}) {
|
||||
globalLogger.Debugf(format, args...)
|
||||
}
|
||||
|
||||
func Info(msg string, fields ...map[string]interface{}) {
|
||||
globalLogger.Info(msg, fields...)
|
||||
}
|
||||
|
||||
func Infof(format string, args ...interface{}) {
|
||||
globalLogger.Infof(format, args...)
|
||||
}
|
||||
|
||||
func Warn(msg string, fields ...map[string]interface{}) {
|
||||
globalLogger.Warn(msg, fields...)
|
||||
}
|
||||
|
||||
func Warnf(format string, args ...interface{}) {
|
||||
globalLogger.Warnf(format, args...)
|
||||
}
|
||||
|
||||
func Error(msg string, fields ...map[string]interface{}) {
|
||||
globalLogger.Error(msg, fields...)
|
||||
}
|
||||
|
||||
func Errorf(format string, args ...interface{}) {
|
||||
globalLogger.Errorf(format, args...)
|
||||
}
|
||||
|
||||
func Fatal(msg string, fields ...map[string]interface{}) {
|
||||
globalLogger.Fatal(msg, fields...)
|
||||
}
|
||||
|
||||
func Fatalf(format string, args ...interface{}) {
|
||||
globalLogger.Fatalf(format, args...)
|
||||
}
|
||||
|
||||
// SaveLogText menyimpan log dalam format teks dengan pemisah |
|
||||
func (l *Logger) SaveLogText(entry LogEntry) error {
|
||||
// Format log dengan pemisah |
|
||||
logLine := fmt.Sprintf("%s|%s|%s|%s|%s|%s|%s|%s:%d",
|
||||
entry.Timestamp,
|
||||
entry.Level,
|
||||
entry.Service,
|
||||
entry.Message,
|
||||
entry.RequestID,
|
||||
entry.CorrelationID,
|
||||
entry.Duration,
|
||||
entry.File,
|
||||
entry.Line)
|
||||
|
||||
// Tambahkan fields jika ada
|
||||
if len(entry.Fields) > 0 {
|
||||
fieldsStr := ""
|
||||
for k, v := range entry.Fields {
|
||||
fieldsStr += fmt.Sprintf("|%s=%v", k, v)
|
||||
}
|
||||
logLine += fieldsStr
|
||||
}
|
||||
logLine += "\n"
|
||||
|
||||
// Buat direktori jika belum ada
|
||||
if err := os.MkdirAll(l.logDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Tulis ke file dengan mutex lock untuk concurrency safety
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
filePath := filepath.Join(l.logDir, "logs.txt")
|
||||
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.WriteString(logLine); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveLogJSON menyimpan log dalam format JSON
|
||||
func (l *Logger) SaveLogJSON(entry LogEntry) error {
|
||||
jsonData, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Buat direktori jika belum ada
|
||||
if err := os.MkdirAll(l.logDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Tulis ke file dengan mutex lock for concurrency safety
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
filePath := filepath.Join(l.logDir, "logs.json")
|
||||
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.WriteString(string(jsonData) + "\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveLogToDatabase menyimpan log ke database
|
||||
func (l *Logger) SaveLogToDatabase(entry LogEntry) error {
|
||||
// Implementasi penyimpanan ke database
|
||||
// Ini adalah contoh implementasi, sesuaikan dengan struktur database Anda
|
||||
|
||||
// Untuk saat ini, kita akan simpan ke file sebagai placeholder
|
||||
// Anda dapat mengganti ini dengan koneksi database yang sesuai
|
||||
dbLogLine := fmt.Sprintf("DB_LOG: %s|%s|%s|%s\n",
|
||||
entry.Timestamp, entry.Level, entry.Service, entry.Message)
|
||||
|
||||
if err := os.MkdirAll(l.logDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Tulis ke file dengan mutex lock for concurrency safety
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
filePath := filepath.Join(l.logDir, "database_logs.txt")
|
||||
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.WriteString(dbLogLine); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogAndSave melakukan logging dan menyimpan ke semua format
|
||||
func (l *Logger) LogAndSave(level LogLevel, msg string, fields ...map[string]interface{}) {
|
||||
// Panggil fungsi log biasa
|
||||
l.log(level, msg, nil, fields...)
|
||||
|
||||
// Dapatkan entry log yang baru dibuat
|
||||
_, file, line, ok := runtime.Caller(2)
|
||||
var callerFile string
|
||||
var callerLine int
|
||||
if ok {
|
||||
parts := strings.Split(file, "/")
|
||||
if len(parts) > 2 {
|
||||
callerFile = strings.Join(parts[len(parts)-2:], "/")
|
||||
} else {
|
||||
callerFile = file
|
||||
}
|
||||
callerLine = line
|
||||
}
|
||||
|
||||
mergedFields := make(map[string]interface{})
|
||||
for _, f := range fields {
|
||||
for k, v := range f {
|
||||
mergedFields[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
entry := LogEntry{
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
Level: levelStrings[level],
|
||||
Service: l.serviceName,
|
||||
Message: msg,
|
||||
File: callerFile,
|
||||
Line: callerLine,
|
||||
Fields: mergedFields,
|
||||
}
|
||||
|
||||
// Simpan ke semua format
|
||||
go func() {
|
||||
l.SaveLogText(entry)
|
||||
l.SaveLogJSON(entry)
|
||||
l.SaveLogToDatabase(entry)
|
||||
}()
|
||||
}
|
||||
|
||||
// Global fungsi untuk menyimpan log
|
||||
func SaveLogText(entry LogEntry) error {
|
||||
return globalLogger.SaveLogText(entry)
|
||||
}
|
||||
|
||||
func SaveLogJSON(entry LogEntry) error {
|
||||
return globalLogger.SaveLogJSON(entry)
|
||||
}
|
||||
|
||||
func SaveLogToDatabase(entry LogEntry) error {
|
||||
return globalLogger.SaveLogToDatabase(entry)
|
||||
}
|
||||
191
pkg/logger/middleware.go
Normal file
191
pkg/logger/middleware.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// RequestLoggerMiddleware creates a Gin middleware for request logging
|
||||
func RequestLoggerMiddleware(logger *Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Generate request ID if not present
|
||||
requestID := c.GetHeader("X-Request-ID")
|
||||
if requestID == "" {
|
||||
requestID = uuid.New().String()
|
||||
c.Header("X-Request-ID", requestID)
|
||||
}
|
||||
|
||||
// Get correlation ID
|
||||
correlationID := c.GetHeader("X-Correlation-ID")
|
||||
if correlationID == "" {
|
||||
correlationID = uuid.New().String()
|
||||
c.Header("X-Correlation-ID", correlationID)
|
||||
}
|
||||
|
||||
// Create request-scoped logger
|
||||
reqLogger := logger.
|
||||
WithRequestID(requestID).
|
||||
WithCorrelationID(correlationID)
|
||||
|
||||
// Store logger in context
|
||||
c.Set("logger", reqLogger)
|
||||
c.Set("request_id", requestID)
|
||||
c.Set("correlation_id", correlationID)
|
||||
|
||||
// Capture request body for logging if needed
|
||||
var requestBody []byte
|
||||
if c.Request.Body != nil && strings.HasPrefix(c.ContentType(), "application/json") {
|
||||
requestBody, _ = io.ReadAll(c.Request.Body)
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
|
||||
}
|
||||
|
||||
// Start timer
|
||||
start := time.Now()
|
||||
|
||||
// Log request start
|
||||
reqLogger.Info("Request started", map[string]interface{}{
|
||||
"method": c.Request.Method,
|
||||
"path": c.Request.URL.Path,
|
||||
"query": c.Request.URL.RawQuery,
|
||||
"remote_addr": c.Request.RemoteAddr,
|
||||
"user_agent": c.Request.UserAgent(),
|
||||
"content_type": c.ContentType(),
|
||||
"body_size": len(requestBody),
|
||||
})
|
||||
|
||||
// Process request
|
||||
c.Next()
|
||||
|
||||
// Calculate duration
|
||||
duration := time.Since(start)
|
||||
|
||||
// Get response status
|
||||
status := c.Writer.Status()
|
||||
responseSize := c.Writer.Size()
|
||||
|
||||
// Log level based on status code
|
||||
var logLevel LogLevel
|
||||
switch {
|
||||
case status >= 500:
|
||||
logLevel = ERROR
|
||||
case status >= 400:
|
||||
logLevel = WARN
|
||||
default:
|
||||
logLevel = INFO
|
||||
}
|
||||
|
||||
// Log request completion
|
||||
fields := map[string]interface{}{
|
||||
"method": c.Request.Method,
|
||||
"path": c.Request.URL.Path,
|
||||
"status": status,
|
||||
"duration": duration.String(),
|
||||
"duration_ms": duration.Milliseconds(),
|
||||
"response_size": responseSize,
|
||||
"client_ip": c.ClientIP(),
|
||||
"user_agent": c.Request.UserAgent(),
|
||||
"content_type": c.ContentType(),
|
||||
"content_length": c.Request.ContentLength,
|
||||
}
|
||||
|
||||
// Add query parameters if present
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
fields["query"] = c.Request.URL.RawQuery
|
||||
}
|
||||
|
||||
// Add error information if present
|
||||
if len(c.Errors) > 0 {
|
||||
errors := make([]string, len(c.Errors))
|
||||
for i, err := range c.Errors {
|
||||
errors[i] = err.Error()
|
||||
}
|
||||
fields["errors"] = errors
|
||||
}
|
||||
|
||||
reqLogger.log(logLevel, "Request completed", &duration, fields)
|
||||
}
|
||||
}
|
||||
|
||||
// GetLoggerFromContext retrieves the logger from Gin context
|
||||
func GetLoggerFromContext(c *gin.Context) *Logger {
|
||||
if logger, exists := c.Get("logger"); exists {
|
||||
if l, ok := logger.(*Logger); ok {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return globalLogger
|
||||
}
|
||||
|
||||
// GetRequestIDFromContext retrieves the request ID from Gin context
|
||||
func GetRequestIDFromContext(c *gin.Context) string {
|
||||
if requestID, exists := c.Get("request_id"); exists {
|
||||
if id, ok := requestID.(string); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetCorrelationIDFromContext retrieves the correlation ID from Gin context
|
||||
func GetCorrelationIDFromContext(c *gin.Context) string {
|
||||
if correlationID, exists := c.Get("correlation_id"); exists {
|
||||
if id, ok := correlationID.(string); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DatabaseLoggerMiddleware creates middleware for database operation logging
|
||||
func DatabaseLoggerMiddleware(logger *Logger, serviceName string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
reqLogger := GetLoggerFromContext(c).WithService(serviceName)
|
||||
c.Set("db_logger", reqLogger)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetDBLoggerFromContext retrieves the database logger from Gin context
|
||||
func GetDBLoggerFromContext(c *gin.Context) *Logger {
|
||||
if logger, exists := c.Get("db_logger"); exists {
|
||||
if l, ok := logger.(*Logger); ok {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return GetLoggerFromContext(c)
|
||||
}
|
||||
|
||||
// ServiceLogger creates a service-specific logger
|
||||
func ServiceLogger(serviceName string) *Logger {
|
||||
return globalLogger.WithService(serviceName)
|
||||
}
|
||||
|
||||
// AuthServiceLogger returns a logger for auth service
|
||||
func AuthServiceLogger() *Logger {
|
||||
return ServiceLogger("auth-service")
|
||||
}
|
||||
|
||||
// BPJSServiceLogger returns a logger for BPJS service
|
||||
func BPJSServiceLogger() *Logger {
|
||||
return ServiceLogger("bpjs-service")
|
||||
}
|
||||
|
||||
// RetribusiServiceLogger returns a logger for retribusi service
|
||||
func RetribusiServiceLogger() *Logger {
|
||||
return ServiceLogger("retribusi-service")
|
||||
}
|
||||
|
||||
// DatabaseServiceLogger returns a logger for database operations
|
||||
func DatabaseServiceLogger() *Logger {
|
||||
return ServiceLogger("database-service")
|
||||
}
|
||||
|
||||
// MiddlewareServiceLogger returns a logger for middleware operations
|
||||
func MiddlewareServiceLogger() *Logger {
|
||||
return ServiceLogger("middleware-service")
|
||||
}
|
||||
54
pkg/utils/etag.go
Normal file
54
pkg/utils/etag.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseETag extracts the ETag value from HTTP ETag header
|
||||
// Handles both strong ETags ("123") and weak ETags (W/"123")
|
||||
func ParseETag(etag string) string {
|
||||
if etag == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Remove W/ prefix for weak ETags
|
||||
if strings.HasPrefix(etag, "W/") {
|
||||
etag = etag[2:]
|
||||
}
|
||||
|
||||
// Remove surrounding quotes
|
||||
if len(etag) >= 2 && strings.HasPrefix(etag, "\"") && strings.HasSuffix(etag, "\"") {
|
||||
etag = etag[1 : len(etag)-1]
|
||||
}
|
||||
|
||||
return etag
|
||||
}
|
||||
|
||||
// FormatETag formats a version ID into a proper HTTP ETag header value
|
||||
func FormatETag(versionId string, weak bool) string {
|
||||
if versionId == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if weak {
|
||||
return fmt.Sprintf(`W/"%s"`, versionId)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`"%s"`, versionId)
|
||||
}
|
||||
|
||||
// IsValidETag validates if the given string is a valid ETag format
|
||||
func IsValidETag(etag string) bool {
|
||||
if etag == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for weak ETag format
|
||||
if strings.HasPrefix(etag, "W/") {
|
||||
etag = etag[2:]
|
||||
}
|
||||
|
||||
// Must be quoted
|
||||
return len(etag) >= 2 && strings.HasPrefix(etag, "\"") && strings.HasSuffix(etag, "\"")
|
||||
}
|
||||
1
pkg/validator/validator
Normal file
1
pkg/validator/validator
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
0
scripts/scripts
Normal file
0
scripts/scripts
Normal file
1740
tools/general/generate-handler.go
Normal file
1740
tools/general/generate-handler.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user