first commit

This commit is contained in:
2026-04-22 09:11:46 +07:00
parent 5c577475f1
commit 27694798c2
19 changed files with 2748 additions and 2274 deletions
+278
View File
@@ -0,0 +1,278 @@
package aplicare
import (
"api-service/internal/config"
"api-service/internal/database"
"api-service/pkg/logger"
"context"
"encoding/json"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"net/http"
"os"
"sync"
"time"
)
type AplicaresHandler struct {
syncer *Syncer
simrs *SimrsDB
validator *validator.Validate
logger logger.Logger
cfg *config.Config
once sync.Once
interval time.Duration
}
type AplicaresHandlerConfig struct {
Config *config.Config
Logger logger.Logger
Validator *validator.Validate
}
func NewAplicaresHandler(cfg AplicaresHandlerConfig) *AplicaresHandler {
statePath := os.Getenv("APLICARES_STATE_PATH")
if statePath == "" {
statePath = "./data/state.json"
}
interval, err := time.ParseDuration(os.Getenv("APLICARES_SYNC_INTERVAL"))
if err != nil || interval <= 0 {
interval = 5 * time.Minute
}
dryRun := os.Getenv("APLICARES_DRY_RUN") == "true"
_ = os.MkdirAll("./data", 0755)
db := database.New(cfg.Config)
simrs := NewSimrsDB(db)
syncer := NewSyncer(simrs, cfg.Config, statePath, dryRun)
h := &AplicaresHandler{
syncer: syncer,
simrs: simrs,
validator: cfg.Validator,
logger: cfg.Logger,
cfg: cfg.Config,
interval: interval,
}
if dryRun {
h.logger.Info("=== APLICARES DRY RUN — tidak kirim ke BPJS ===", nil)
} else {
h.logger.Info("=== APLICARES LIVE MODE ===", nil)
}
return h
}
// =============================================
// SCHEDULER
// =============================================
func (h *AplicaresHandler) StartScheduler(ctx context.Context) {
h.once.Do(func() {
go h.runScheduler(ctx)
})
}
func (h *AplicaresHandler) runScheduler(ctx context.Context) {
h.logger.Info("Scheduler started", map[string]interface{}{
"interval": h.interval.String(),
})
// Langsung sync sekali saat startup
h.runOnce(ctx)
ticker := time.NewTicker(h.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
h.runOnce(ctx)
case <-ctx.Done():
h.logger.Info("Scheduler stopped", nil)
return
}
}
}
func (h *AplicaresHandler) runOnce(ctx context.Context) {
result, err := h.syncer.Sync(ctx)
if err != nil {
h.logger.Errorf("Sync error: %v", err)
return
}
h.logger.Info("Sync selesai", map[string]interface{}{
"total": result.TotalRooms,
"changed": result.Changed,
"posted": result.Posted,
"dry_run": result.DryRun,
"errors": len(result.Errors),
})
}
// =============================================
// HTTP HANDLERS
// =============================================
// GetBeds — GET /api/v1/aplicares/beds
func (h *AplicaresHandler) GetBeds(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
ruangans, err := h.simrs.GetRuangan(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
detailMap, err := h.simrs.GetBedDetails(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
beds := buildBedData(ruangans, detailMap)
c.JSON(http.StatusOK, gin.H{
"total": len(beds),
"timestamp": time.Now().Format(time.RFC3339),
"data": beds,
})
}
func (h *AplicaresHandler) GetState(c *gin.Context) {
statePath := os.Getenv("APLICARES_STATE_PATH")
if statePath == "" {
statePath = "./data/state.json"
}
state, err := LoadState(statePath)
if err != nil || state == nil {
c.JSON(http.StatusOK, gin.H{"message": "belum ada state", "data": nil})
return
}
c.JSON(http.StatusOK, state)
}
func (h *AplicaresHandler) TriggerSync(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 120*time.Second)
defer cancel()
result, err := h.syncer.Sync(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
// CheckBPJS — GET /api/v1/aplicares/check-bpjs
func (h *AplicaresHandler) CheckBPJS(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
bpjs := NewBpjsClient(h.cfg.Bpjs)
start := time.Now()
kamars, err := bpjs.BacaKamar(ctx, 1, 60)
elapsed := time.Since(start).Milliseconds()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"status": "gagal",
"keterangan": "tidak bisa konek ke BPJS",
"error": err.Error(),
"response_ms": elapsed,
})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"keterangan": "BPJS bisa diakses",
"total_kamar": len(kamars),
"sample": kamars,
"response_ms": elapsed,
})
}
func (h *AplicaresHandler) GetRefKelas(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
bpjs := NewBpjsClient(h.cfg.Bpjs)
start := time.Now()
kelas, err := bpjs.GetRefKelas(ctx)
elapsed := time.Since(start).Milliseconds()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"status": "gagal",
"error": err.Error(),
"response_ms": elapsed,
})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"total": len(kelas),
"data": kelas,
"response_ms": elapsed,
})
}
func (h *AplicaresHandler) GetSyncLogs(c *gin.Context) {
content, err := os.ReadFile("./logs/sync.log")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"message": "belum ada log",
"data": []interface{}{},
})
return
}
// Parse JSON lines
lines := splitLines(string(content))
var logs []interface{}
for _, line := range lines {
if line == "" {
continue
}
var entry interface{}
if err := json.Unmarshal([]byte(line), &entry); err == nil {
logs = append(logs, entry)
}
}
// Ambil 100 terakhir
if len(logs) > 100 {
logs = logs[len(logs)-100:]
}
// Balik urutan — terbaru di atas
for i, j := 0, len(logs)-1; i < j; i, j = i+1, j-1 {
logs[i], logs[j] = logs[j], logs[i]
}
c.JSON(http.StatusOK, gin.H{
"total": len(logs),
"data": logs,
})
}
func splitLines(s string) []string {
var lines []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
if start < len(s) {
lines = append(lines, s[start:])
}
return lines
}
+270
View File
@@ -0,0 +1,270 @@
package aplicare
import (
"api-service/internal/config"
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// =============================================
// BPJS CLIENT
// Semua komunikasi ke BPJS Aplicares API ada di sini
// Header yang dikirim: X-cons-id, X-timestamp, X-signature SAJA
// Tidak pakai user_key — itu khusus VClaim, bukan Aplicares
// =============================================
type BpjsClient struct {
baseURL string
consID string
consSecret string
kodePPK string
httpClient *http.Client
}
func NewBpjsClient(cfg config.BpjsConfig) *BpjsClient {
kodePPK := os.Getenv("APLICARES_KODE_PPK")
if kodePPK == "" {
kodePPK = "1323R001"
}
// Pakai APLICARES_BPJS_* kalau ada, fallback ke BPJS_* dari config
baseURL := os.Getenv("APLICARES_BPJS_BASEURL")
if baseURL == "" {
baseURL = cfg.BaseURL
}
consID := os.Getenv("APLICARES_BPJS_CONSID")
if consID == "" {
consID = cfg.ConsID
}
secretKey := os.Getenv("APLICARES_BPJS_SECRETKEY")
if secretKey == "" {
secretKey = cfg.SecretKey
}
timeout := cfg.Timeout
if timeout == 0 {
timeout = 30 * time.Second
}
return &BpjsClient{
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/",
consID: strings.TrimSpace(consID),
consSecret: strings.TrimSpace(secretKey),
kodePPK: kodePPK,
httpClient: &http.Client{
Timeout: timeout,
},
}
}
// createHeaders — HANYA 3 header untuk Aplicares
func (c *BpjsClient) createHeaders() map[string]string {
timestamp := fmt.Sprintf("%d", time.Now().Unix())
message := c.consID + "&" + timestamp
mac := hmac.New(sha256.New, []byte(c.consSecret))
mac.Write([]byte(message))
signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return map[string]string{
"Content-Type": "application/json",
"Accept": "application/json",
"X-cons-id": c.consID,
"X-timestamp": timestamp,
"X-signature": signature,
}
}
// =============================================
// HTTP HELPERS
// =============================================
type bpjsResponse struct {
Metadata struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"metadata"`
Response struct {
List json.RawMessage `json:"list"`
} `json:"response"`
}
func (c *BpjsClient) get(ctx context.Context, endpoint string) (json.RawMessage, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+endpoint, nil)
if err != nil {
return nil, err
}
for k, v := range c.createHeaders() {
req.Header.Set(k, v)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("GET %s gagal: %w", endpoint, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("HTTP error: %d - %s", resp.StatusCode, string(body))
}
var r bpjsResponse
if err := json.Unmarshal(body, &r); err != nil {
return nil, fmt.Errorf("parse response gagal: %w", err)
}
if r.Metadata.Code != 1 {
return nil, fmt.Errorf("BPJS error code %d: %s", r.Metadata.Code, r.Metadata.Message)
}
return r.Response.List, nil
}
func (c *BpjsClient) post(ctx context.Context, endpoint string, payload interface{}) (int, string, error) {
body, err := json.Marshal(payload)
if err != nil {
return 0, "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+endpoint, bytes.NewReader(body))
if err != nil {
return 0, "", err
}
for k, v := range c.createHeaders() {
req.Header.Set(k, v)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return 0, "", fmt.Errorf("POST %s gagal: %w", endpoint, err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return 0, "", fmt.Errorf("HTTP error: %d - %s", resp.StatusCode, string(respBody))
}
var r bpjsResponse
if err := json.Unmarshal(respBody, &r); err != nil {
return 0, "", fmt.Errorf("parse response gagal: %w", err)
}
return r.Metadata.Code, r.Metadata.Message, nil
}
// =============================================
// BPJS OPERATIONS
// =============================================
// BacaKamar membaca daftar kamar yang ada di BPJS
func (c *BpjsClient) BacaKamar(ctx context.Context, start, limit int) ([]map[string]interface{}, error) {
endpoint := fmt.Sprintf("aplicaresws/rest/bed/read/%s/%d/%d", c.kodePPK, start, limit)
raw, err := c.get(ctx, endpoint)
if err != nil {
return nil, err
}
var list []map[string]interface{}
if err := json.Unmarshal(raw, &list); err != nil {
return nil, err
}
return list, nil
}
// PostKamar upsert kamar ke BPJS — coba update dulu, kalau gagal baru create
func (c *BpjsClient) PostKamar(ctx context.Context, bed BedData) error {
payload := map[string]interface{}{
"kodekelas": bed.KodeKelas,
"koderuang": bed.KodeRuang,
"namaruang": bed.NamaRuang,
"kapasitas": bed.Kapasitas,
"tersedia": bed.Tersedia,
"tersediapria": bed.TersediaPria,
"tersediawanita": bed.TersediaWanita,
"tersediapriawanita": bed.TersediaPriaWanita,
}
// Coba update dulu
code, _, err := c.post(ctx, fmt.Sprintf("aplicaresws/rest/bed/update/%s", c.kodePPK), payload)
if err == nil && code == 1 {
return nil
}
// Fallback ke create
code, msg, err := c.post(ctx, fmt.Sprintf("aplicaresws/rest/bed/create/%s", c.kodePPK), payload)
if err != nil {
return fmt.Errorf("create kamar %s gagal: %w", bed.KodeRuang, err)
}
if code != 1 {
return fmt.Errorf("create kamar %s: %s", bed.KodeRuang, msg)
}
return nil
}
// HapusKamar hapus kamar dari BPJS
func (c *BpjsClient) HapusKamar(ctx context.Context, kodekelas, koderuang string) error {
payload := map[string]string{
"kodekelas": kodekelas,
"koderuang": koderuang,
}
code, msg, err := c.post(ctx, fmt.Sprintf("aplicaresws/rest/bed/delete/%s", c.kodePPK), payload)
if err != nil {
return fmt.Errorf("hapus kamar %s gagal: %w", koderuang, err)
}
if code != 1 {
return fmt.Errorf("hapus kamar %s: %s", koderuang, msg)
}
return nil
}
// Flush hapus kamar di BPJS yang tidak ada lagi di SIMRS
func (c *BpjsClient) Flush(ctx context.Context, currentBeds []BedData) (int, []string) {
activeRooms := make(map[string]bool, len(currentBeds))
for _, b := range currentBeds {
activeRooms[b.KodeRuang] = true
}
bpjsKamars, err := c.BacaKamar(ctx, 1, 200)
if err != nil {
return 0, []string{fmt.Sprintf("BacaKamar flush gagal (tidak fatal): %v", err)}
}
flushed := 0
var errs []string
for _, kamar := range bpjsKamars {
kodeRuang, _ := kamar["koderuang"].(string)
kodeKelas, _ := kamar["kodekelas"].(string)
if !activeRooms[kodeRuang] {
if err := c.HapusKamar(ctx, kodeKelas, kodeRuang); err != nil {
errs = append(errs, fmt.Sprintf("hapus %s gagal: %v", kodeRuang, err))
continue
}
flushed++
}
}
return flushed, errs
}
// GetRefKelas membaca referensi kelas dari BPJS
func (c *BpjsClient) GetRefKelas(ctx context.Context) ([]map[string]interface{}, error) {
raw, err := c.get(ctx, "aplicaresws/rest/ref/kelas")
if err != nil {
return nil, err
}
var list []map[string]interface{}
if err := json.Unmarshal(raw, &list); err != nil {
return nil, err
}
return list, nil
}
+155
View File
@@ -0,0 +1,155 @@
package aplicare
import (
"api-service/internal/database"
"context"
"database/sql"
"fmt"
)
type Ruangan struct {
No int `db:"no"`
Nama string `db:"nama"`
JumlahTT int `db:"jumlah_tt"`
KodeRuang sql.NullString `db:"kode_aplicare"` // diisi manual, dikirim ke BPJS
NamaRuang sql.NullString `db:"nama_ruang"` // diisi manual, dikirim ke BPJS
KelasRuang sql.NullString `db:"kode_kelas"` // diisi manual, dikirim ke BPJS
}
// BedDetail adalah data dari tabel m_detail_tempat_tidur
// Setiap row = 1 bed yang sedang terisi
// idxruang bertipe varchar di DB, relasi ke m_ruang.no (integer)
type BedDetail struct {
IdxRuang string `db:"idxruang"`
}
// =============================================
// SIMRS READER
// =============================================
type SimrsDB struct {
db database.Service
}
func NewSimrsDB(db database.Service) *SimrsDB {
return &SimrsDB{db: db}
}
// GetRuangan membaca semua ruangan aktif dari m_ruang
// Hanya ruangan yang sudah di-mapping manual (kode_ruang + kode_kelas tidak kosong)
func (s *SimrsDB) GetRuangan(ctx context.Context) ([]Ruangan, error) {
db, err := s.db.GetDB("simrs")
if err != nil {
return nil, fmt.Errorf("koneksi simrs gagal: %w", err)
}
query := `
SELECT no, nama, jumlah_tt,kode_aplicare, nama_ruang, kode_kelas
FROM m_ruang
where st_aktif = 1 AND kode_aplicare IS NOT NULL
ORDER BY no
`
rows, err := db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("query m_ruang gagal: %w", err)
}
defer rows.Close()
var result []Ruangan
for rows.Next() {
var r Ruangan
if err := rows.Scan(
&r.No, &r.Nama, &r.JumlahTT,
&r.KodeRuang, &r.NamaRuang, &r.KelasRuang,
); err != nil {
return nil, fmt.Errorf("scan m_ruang gagal: %w", err)
}
result = append(result, r)
}
return result, rows.Err()
}
// GetBedDetails membaca semua bed yang terisi dari m_detail_tempat_tidur
// Return map[idxruang][]BedDetail — dikelompokkan per ruangan
// Setiap row = 1 bed terisi, COUNT per idxruang = total terisi
func (s *SimrsDB) GetBedDetails(ctx context.Context) (map[string][]BedDetail, error) {
db, err := s.db.GetDB("simrs")
if err != nil {
return nil, fmt.Errorf("koneksi simrs gagal: %w", err)
}
query := `
SELECT idxruang
FROM m_detail_tempat_tidur
WHERE status IN (1, 5)
ORDER BY idxruang
`
rows, err := db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("query m_detail_tempat_tidur gagal: %w", err)
}
defer rows.Close()
result := make(map[string][]BedDetail)
for rows.Next() {
var d BedDetail
if err := rows.Scan(&d.IdxRuang); err != nil {
return nil, fmt.Errorf("scan m_detail_tempat_tidur gagal: %w", err)
}
result[d.IdxRuang] = append(result[d.IdxRuang], d)
}
return result, rows.Err()
}
// =============================================
// TRANSFORM
// =============================================
// BedData adalah hasil agregasi per ruangan — siap kirim ke BPJS
type BedData struct {
No int `json:"no"`
KodeKelas string `json:"kodekelas"`
KodeRuang string `json:"koderuang"`
NamaRuang string `json:"namaruang"`
Kapasitas int `json:"kapasitas"`
Tersedia int `json:"tersedia"`
TersediaPria int `json:"tersediapria"`
TersediaWanita int `json:"tersediawanita"`
TersediaPriaWanita int `json:"tersediapriawanita"`
}
// buildBedData mengubah data SIMRS menjadi BedData siap kirim ke BPJS
// tersedia = jumlah_tt - COUNT(row di m_detail per ruangan)
// karena setiap row di m_detail = 1 bed yang terisi
func buildBedData(ruangans []Ruangan, detailMap map[string][]BedDetail) []BedData {
var result []BedData
for _, r := range ruangans {
// detailMap berisi semua bed yang tidak tersedia (status != 0)
// tersedia = jumlah_tt - jumlah yang tidak tersedia
terisi := len(detailMap[fmt.Sprintf("%d", r.No)])
tersedia := r.JumlahTT - terisi
if tersedia < 0 {
tersedia = 0
}
// Skip ruangan dengan kapasitas 0 — tidak valid untuk dikirim ke BPJS
if r.JumlahTT == 0 {
continue
}
result = append(result, BedData{
No: r.No,
KodeKelas: r.KelasRuang.String,
KodeRuang: r.KodeRuang.String,
NamaRuang: r.NamaRuang.String,
Kapasitas: r.JumlahTT,
Tersedia: tersedia,
TersediaPria: 0,
TersediaWanita: 0,
TersediaPriaWanita: tersedia,
})
}
return result
}
+141
View File
@@ -0,0 +1,141 @@
package aplicare
import (
"encoding/json"
"os"
"time"
)
// RoomSnapshot adalah snapshot nilai per ruangan
type RoomSnapshot struct {
Kapasitas int `json:"kapasitas"`
Tersedia int `json:"tersedia"`
TersediaPria int `json:"tersedia_pria"`
TersediaWanita int `json:"tersedia_wanita"`
TersediaPriaWanita int `json:"tersedia_pria_wanita"`
}
// RoomState adalah state per ruangan dengan perbandingan old vs new
type RoomState struct {
KodeRuang string `json:"kode_ruang"`
KodeKelas string `json:"kodekelas"`
NamaRuang string `json:"nama_ruang"`
OldValue RoomSnapshot `json:"old_value"`
NewValue RoomSnapshot `json:"new_value"`
Changed bool `json:"changed"`
LastSynced string `json:"last_synced"`
}
// State adalah struktur utama state.json
type State struct {
LastUpdated string `json:"last_updated"`
Rooms map[string]RoomState `json:"rooms"` // key: kode_ruang
}
// LoadState membaca state.json dari disk
// Mengembalikan empty State (bukan error) jika file belum ada
func LoadState(path string) (*State, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return &State{Rooms: make(map[string]RoomState)}, nil
}
if err != nil {
return nil, err
}
var state State
if err := json.Unmarshal(data, &state); err != nil {
return nil, err
}
if state.Rooms == nil {
state.Rooms = make(map[string]RoomState)
}
return &state, nil
}
// SaveState menulis state ke disk
func SaveState(path string, state *State) error {
state.LastUpdated = time.Now().Format(time.RFC3339)
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// ComputeDiff membandingkan data SIMRS terbaru dengan state lama.
// Mengembalikan state baru dengan field Changed=true hanya untuk yang berubah.
func ComputeDiff(old *State, current []BedData) *State {
newState := &State{
Rooms: make(map[string]RoomState),
}
now := time.Now().Format(time.RFC3339)
for _, bed := range current {
newSnap := RoomSnapshot{
Kapasitas: bed.Kapasitas,
Tersedia: bed.Tersedia,
TersediaPria: bed.TersediaPria,
TersediaWanita: bed.TersediaWanita,
TersediaPriaWanita: bed.TersediaPriaWanita,
}
oldRoom, exists := old.Rooms[bed.KodeRuang]
oldSnap := oldRoom.NewValue
if !exists {
oldSnap = RoomSnapshot{}
}
changed := !snapshotEqual(oldSnap, newSnap)
// Kalau sebelumnya sudah ada lastSynced, pertahankan
lastSynced := oldRoom.LastSynced
if changed {
lastSynced = now
}
newState.Rooms[bed.KodeRuang] = RoomState{
KodeRuang: bed.KodeRuang,
KodeKelas: bed.KodeKelas,
NamaRuang: bed.NamaRuang,
OldValue: oldSnap,
NewValue: newSnap,
Changed: changed,
LastSynced: lastSynced,
}
}
return newState
}
// GetChangedBeds mengembalikan hanya BedData yang berubah dari state
func GetChangedBeds(state *State, allBeds []BedData) []BedData {
changedMap := make(map[string]bool)
for kode, room := range state.Rooms {
if room.Changed {
changedMap[kode] = true
}
}
var changed []BedData
for _, bed := range allBeds {
if changedMap[bed.KodeRuang] {
changed = append(changed, bed)
}
}
return changed
}
func snapshotEqual(a, b RoomSnapshot) bool {
return a.Kapasitas == b.Kapasitas &&
a.Tersedia == b.Tersedia &&
a.TersediaPria == b.TersediaPria &&
a.TersediaWanita == b.TersediaWanita &&
a.TersediaPriaWanita == b.TersediaPriaWanita
}
+158
View File
@@ -0,0 +1,158 @@
package aplicare
import (
"api-service/internal/config"
"context"
"fmt"
"time"
)
type SyncResult struct {
RunAt string `json:"run_at"`
TotalRooms int `json:"total_rooms"`
Changed int `json:"changed"`
Posted int `json:"posted"`
Errors []string `json:"errors,omitempty"`
DryRun bool `json:"dry_run"`
}
type Syncer struct {
simrs *SimrsDB
bpjs *BpjsClient
statePath string
dryRun bool
}
func NewSyncer(simrs *SimrsDB, cfg *config.Config, statePath string, dryRun bool) *Syncer {
return &Syncer{
simrs: simrs,
bpjs: NewBpjsClient(cfg.Bpjs),
statePath: statePath,
dryRun: dryRun,
}
}
func (s *Syncer) Sync(ctx context.Context) (*SyncResult, error) {
result := &SyncResult{
RunAt: time.Now().Format(time.RFC3339),
DryRun: s.dryRun,
}
// 1. Baca dari SIMRS
ruangans, err := s.simrs.GetRuangan(ctx)
if err != nil {
return nil, fmt.Errorf("baca m_ruang gagal: %w", err)
}
detailMap, err := s.simrs.GetBedDetails(ctx)
if err != nil {
return nil, fmt.Errorf("baca m_detail gagal: %w", err)
}
// 2. Transform
beds := buildBedData(ruangans, detailMap)
result.TotalRooms = len(beds)
// 3. Diff vs state lama
oldState, err := LoadState(s.statePath)
if err != nil || oldState == nil {
oldState = &State{Rooms: make(map[string]RoomState)}
}
newState := ComputeDiff(oldState, beds)
changedBeds := GetChangedBeds(newState, beds)
result.Changed = len(changedBeds)
// 4. Dry run — tampilkan perubahan di terminal
if s.dryRun {
if len(changedBeds) == 0 {
fmt.Println("[DRY RUN] Tidak ada perubahan")
} else {
fmt.Printf("[DRY RUN] %d ruangan berubah:\n", len(changedBeds))
for _, bed := range changedBeds {
old := newState.Rooms[bed.KodeRuang].OldValue
fmt.Printf(" → %-30s | kelas: %-6s | kapasitas: %d | tersedia: %d → %d\n",
bed.NamaRuang,
bed.KodeKelas,
bed.Kapasitas,
old.Tersedia,
bed.Tersedia,
)
result.Posted++
}
}
if err := SaveState(s.statePath, newState); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("simpan state gagal: %v", err))
}
WriteBatchLog(result)
return result, nil
}
// 5. POST ke BPJS hanya yang berubah
if len(changedBeds) > 0 {
fmt.Printf("[SYNC] %d ruangan akan dikirim ke BPJS:\n", len(changedBeds))
}
for _, bed := range changedBeds {
if bed.Kapasitas == 0 {
continue
}
oldTersedia := newState.Rooms[bed.KodeRuang].OldValue.Tersedia
start := time.Now()
err := s.bpjs.PostKamar(ctx, bed)
elapsed := time.Since(start).Milliseconds()
if err != nil {
msg := fmt.Sprintf("POST %s gagal: %v", bed.KodeRuang, err)
result.Errors = append(result.Errors, msg)
fmt.Printf(" → %-30s | kelas: %-6s | tersedia: %d → %d | [GAGAL] %v\n",
bed.NamaRuang, bed.KodeKelas, oldTersedia, bed.Tersedia, err)
WriteLog(SyncLog{
KodeRuang: bed.KodeRuang,
NamaRuang: bed.NamaRuang,
KodeKelas: bed.KodeKelas,
Kapasitas: bed.Kapasitas,
Tersedia: bed.Tersedia,
Action: "post",
Status: "gagal",
Error: err.Error(),
ResponseMs: elapsed,
})
continue
}
fmt.Printf(" → %-30s | kelas: %-6s | tersedia: %d → %d | [SUKSES] %dms\n",
bed.NamaRuang, bed.KodeKelas, oldTersedia, bed.Tersedia, elapsed)
WriteLog(SyncLog{
KodeRuang: bed.KodeRuang,
NamaRuang: bed.NamaRuang,
KodeKelas: bed.KodeKelas,
Kapasitas: bed.Kapasitas,
Tersedia: bed.Tersedia,
Action: "post",
Status: "sukses",
ResponseMs: elapsed,
})
result.Posted++
if room, ok := newState.Rooms[bed.KodeRuang]; ok {
room.OldValue = room.NewValue
room.Changed = false
room.LastSynced = time.Now().Format(time.RFC3339)
newState.Rooms[bed.KodeRuang] = room
}
}
// 6. Simpan state
if err := SaveState(s.statePath, newState); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("simpan state gagal: %v", err))
}
// 7. Tulis ringkasan ke log
WriteBatchLog(result)
return result, nil
}
+96
View File
@@ -0,0 +1,96 @@
package aplicare
import (
"encoding/json"
"fmt"
"os"
"time"
)
type SyncLog struct {
Timestamp string `json:"timestamp"`
KodeRuang string `json:"kode_ruang,omitempty"`
NamaRuang string `json:"nama_ruang,omitempty"`
KodeKelas string `json:"kode_kelas,omitempty"`
Kapasitas int `json:"kapasitas,omitempty"`
Tersedia int `json:"tersedia,omitempty"`
Action string `json:"action"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
ResponseMs int64 `json:"response_ms,omitempty"`
}
var logPath = "./logs/sync.log"
func init() {
_ = os.MkdirAll("./logs", 0755)
}
// WriteLog menulis 1 entry log ke sync.log
func WriteLog(entry SyncLog) {
entry.Timestamp = time.Now().Format(time.RFC3339)
writeToFile(entry)
}
// WriteBatchLog menulis ringkasan 1 run sync
func WriteBatchLog(result *SyncResult) {
if result == nil {
return
}
status := "sukses"
if len(result.Errors) > 0 {
status = "partial"
}
if result.Posted == 0 && result.Changed > 0 {
status = "gagal"
}
summary := map[string]interface{}{
"timestamp": time.Now().Format(time.RFC3339),
"action": "batch_sync",
"total_rooms": result.TotalRooms,
"changed": result.Changed,
"posted": result.Posted,
"dry_run": result.DryRun,
"status": status,
"errors": result.Errors,
}
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return
}
defer f.Close()
line, _ := json.Marshal(summary)
_, _ = f.Write(append(line, '\n'))
// Rotasi log — jaga ukuran file max 5MB
rotateLogs()
}
func writeToFile(entry SyncLog) {
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
fmt.Printf("gagal buka log file: %v\n", err)
return
}
defer f.Close()
line, _ := json.Marshal(entry)
_, _ = f.Write(append(line, '\n'))
}
// rotateLogs — kalau file > 5MB, rename jadi sync.log.old
func rotateLogs() {
info, err := os.Stat(logPath)
if err != nil {
return
}
// 5MB
if info.Size() > 5*1024*1024 {
_ = os.Rename(logPath, logPath+".old")
}
}
+11 -16
View File
@@ -4,7 +4,6 @@ import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"log"
"os"
@@ -112,24 +111,20 @@ type SatuSehatConfig struct {
// 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 := time.Now().Unix()
message := strings.TrimSpace(cfg.ConsID) + "&" + fmt.Sprint(tstamp)
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)
mac := hmac.New(sha256.New, []byte(strings.TrimSpace(cfg.SecretKey)))
mac.Write([]byte(message))
// to lowercase hexits
hex.EncodeToString(hash.Sum(nil))
// to base64
xSignature := base64.StdEncoding.EncodeToString(hash.Sum(nil))
// BENAR: langsung base64 dari raw bytes (bukan hex dulu)
xSignature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return cfg.ConsID, cfg.SecretKey, cfg.UserKey, fmt.Sprint(tstamp), xSignature
return strings.TrimSpace(cfg.ConsID),
strings.TrimSpace(cfg.SecretKey),
strings.TrimSpace(cfg.UserKey),
fmt.Sprint(tstamp),
xSignature
}
type ConfigBpjs struct {
@@ -0,0 +1,68 @@
package antreanbpjs
import (
"api-service/internal/config"
"api-service/internal/database"
services "api-service/internal/services/bpjs"
"api-service/pkg/logger"
"context"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"log"
"net/http"
"sync"
"time"
)
type VClaimHandler struct {
service services.VClaimService
validator *validator.Validate
logger logger.Logger
config config.BpjsConfig
db database.Service
once sync.Once
}
type VClaimHandlerConfig struct {
Config *config.Config
Logger logger.Logger
Validator *validator.Validate
db database.Service
}
// NewVClaimHandler creates a new VClaimHandler
func NewVClaimHandler(cfg VClaimHandlerConfig) *VClaimHandler {
return &VClaimHandler{
service: services.NewService(cfg.Config.Bpjs),
db: database.New(cfg.Config),
validator: cfg.Validator,
logger: cfg.Logger,
config: cfg.Config.Bpjs,
}
}
func (h *VClaimHandler) GetPoli(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
defer cancel()
h.logger.Info("Processing Get Applicare request", map[string]interface{}{
"endpoint": "/aplicaresws/rest/ref/kelas",
})
endpoint := "antrean/aplicaresws/rest/bed/read/1323R001/1/1"
resp, err := h.service.GetRawResponse(ctx, endpoint)
if err != nil {
h.logger.Errorf("Error in Get Applicare endpoint %s: %s", endpoint, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to connect to BPJS API",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, resp)
}
func (h *VClaimHandler) Gethealth(c *gin.Context) {
log.Println("halo")
}
-604
View File
@@ -1,604 +0,0 @@
// Package peserta handles Peserta BPJS services
// Generated on: 2025-09-07 11:01:18
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"api-service/internal/config"
"api-service/internal/database"
"api-service/internal/models"
"api-service/internal/models/vclaim/peserta"
services "api-service/internal/services/bpjs"
"api-service/pkg/logger"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
)
// PesertaHandler handles Peserta BPJS services
type PesertaHandler struct {
service services.VClaimService
db database.Service
validator *validator.Validate
logger logger.Logger
config config.BpjsConfig
}
// PesertaHandlerConfig contains configuration for PesertaHandler
type PesertaHandlerConfig struct {
Config *config.Config
Logger logger.Logger
Validator *validator.Validate
}
// NewPesertaHandler creates a new PesertaHandler
func NewPesertaHandler(cfg PesertaHandlerConfig) *PesertaHandler {
return &PesertaHandler{
db: database.New(cfg.Config),
service: services.NewService(cfg.Config.Bpjs),
validator: cfg.Validator,
logger: cfg.Logger,
config: cfg.Config.Bpjs,
}
}
// min returns the minimum of two integers
func min(a, b int) int {
if a < b {
return a
}
return b
}
// cleanResponse removes invalid characters and BOM from the response string
func cleanResponse(resp string) string {
// Remove UTF-8 BOM
// Konversi string ke byte slice untuk pengecekan BOM
data := []byte(resp)
// Cek dan hapus semua jenis representasi UTF-8 BOM
// 1. Byte sequence: EF BB BF
if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
data = data[3:]
}
// 2. Unicode character: U+FEFF
if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
data = data[3:]
}
// 3. Zero Width No-Break Space (Unicode)
if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
data = data[3:]
}
// 4. Representasi heksadesimal lainnya
if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
data = data[3:]
}
// Konversi kembali ke string
resp = string(data)
// Hapus karakter null
// Hapus semua karakter kontrol ASCII (0-31) kecuali whitespace yang valid
controlChars := []string{
"\x00", // Null character
"\x01", // Start of Heading
"\x02", // Start of Text
"\x03", // End of Text
"\x04", // End of Transmission (EOT)
"\x05", // Enquiry
"\x06", // Acknowledge
"\x07", // Bell
"\x08", // Backspace
"\x0B", // Vertical Tab
"\x0C", // Form Feed
"\x0E", // Shift Out
"\x0F", // Shift In
"\x10", // Data Link Escape
"\x11", // Device Control 1
"\x12", // Device Control 2
"\x13", // Device Control 3
"\x14", // Device Control 4
"\x15", // Negative Acknowledge
"\x16", // Synchronous Idle
"\x17", // End of Transmission Block
"\x18", // Cancel
"\x19", // End of Medium
"\x1A", // Substitute
"\x1B", // Escape
"\x1C", // File Separator
"\x1D", // Group Separator
"\x1E", // Record Separator
"\x1F", // Unit Separator
}
for _, char := range controlChars {
resp = strings.ReplaceAll(resp, char, "")
}
// Hapus karakter invalid termasuk backtick
invalidChars := []string{
"¢", // Cent sign
"\u00a2", // Cent sign Unicode
"\u0080", // Control character
"`", // Backtick
"´", // Acute accent
"", // Left single quote
"", // Right single quote
"“", // Left double quote
"”", // Right double quote
}
for _, char := range invalidChars {
resp = strings.ReplaceAll(resp, char, "")
}
// Gunakan buffer pool untuk efisiensi memori
var bufPool = sync.Pool{
New: func() interface{} {
return &strings.Builder{}
},
}
buf := bufPool.Get().(*strings.Builder)
defer func() {
buf.Reset()
bufPool.Put(buf)
}()
// Definisikan karakter yang diperbolehkan
allowedChars := map[rune]bool{
'\n': true, '\r': true, '\t': true,
// Tambahkan karakter non-ASCII yang diperbolehkan jika adafalse
// Contoh:
// Latin-1 Supplement
// ASCII printable (32-126) kecuali backtick (96)
'!': true, '"': true, '#': true, '$': true, '%': true, '&': true,
'\'': true, '(': true, ')': true, '*': true, '+': true, ',': true,
'-': true, '.': true, '/': true, '0': true, '1': true, '2': true,
'3': true, '4': true, '5': true, '6': true, '7': true, '8': true,
'9': true, ':': true, ';': true, '<': true, '=': true, '>': true,
'?': true, '@': true, 'A': true, 'B': true, 'C': true, 'D': true,
'E': true, 'F': true, 'G': true, 'H': true, 'I': true, 'J': true,
'K': true, 'L': true, 'M': true, 'N': true, 'O': true, 'P': true,
'Q': true, 'R': true, 'S': true, 'T': true, 'U': true, 'V': true,
'W': true, 'X': true, 'Y': true, 'Z': true, '[': true, '\\': true,
']': true, '^': true, '_': true, 'a': true, 'b': true, 'c': true,
'd': true, 'e': true, 'f': true, 'g': true, 'h': true, 'i': true,
'j': true, 'k': true, 'l': true, 'm': true, 'n': true, 'o': true,
'p': true, 'q': true, 'r': true, 's': true, 't': true, 'u': true,
'v': true, 'w': true, 'x': true, 'y': true, 'z': true, '{': true,
'|': true, '}': true, '~': true,
// Latin-1 Supplement
'¡': true, '¢': true, '£': true, '¤': true, '¥': true, '¦': true,
'§': true, '¨': true, '©': true, 'ª': true, '«': true, '¬': true,
'®': true, '¯': true, '°': true, '±': true, '²': true, '³': true,
'´': true, 'µ': true, '¶': true, '·': true, '¸': true, '¹': true,
'º': true, '»': true, '¼': true, '½': true, '¾': true, '¿': true,
// Huruf Latin dengan diakritik (Lowercase)
'á': true, 'é': true, 'í': true, 'ó': true, 'ú': true, 'ý': true, 'þ': true,
'à': true, 'è': true, 'ì': true, 'ò': true, 'ù': true,
'â': true, 'ê': true, 'î': true, 'ô': true, 'û': true,
'ä': true, 'ë': true, 'ï': true, 'ö': true, 'ü': true, 'ÿ': true,
'ã': true, 'õ': true, 'ñ': true, 'ç': true,
'ā': true, 'ē': true, 'ī': true, 'ō': true, 'ū': true,
'ă': true, 'đ': true, 'ħ': true, 'ij': true, 'ĸ': true, 'ł': true,
'ŋ': true, 'œ': true, 'ŧ': true, 'ß': true,
// Huruf Latin dengan diakritik (Uppercase)
'Á': true, 'É': true, 'Í': true, 'Ó': true, 'Ú': true, 'Ý': true, 'Þ': true,
'À': true, 'È': true, 'Ì': true, 'Ò': true, 'Ù': true,
'Â': true, 'Ê': true, 'Î': true, 'Ô': true, 'Û': true,
'Ä': true, 'Ë': true, 'Ï': true, 'Ö': true, 'Ü': true,
'Ã': true, 'Õ': true, 'Ñ': true, 'Ç': true,
'Ā': true, 'Ē': true, 'Ī': true, 'Ō': true, 'Ū': true,
'Ă': true, 'Đ': true, 'Ħ': true, 'IJ': true, 'Ł': true,
'Ŋ': true, 'Œ': true, 'Ŧ': true, 'ẞ': true,
// Karakter Nordik dan lainnya
'Å': true, 'å': true, 'Æ': true, 'æ': true, 'Ø': true, 'ø': true,
'ſ': true, 'ʼn': true, 'ŀ': true,
// Tanda baca dan simbol matematika
'': true, '': true, '—': true, '―': true, '‖': true, '‗': true,
'†': true, '‡': true, '•': true, '‣': true, '': true, '‥': true,
'…': true, '‧': true, '‰': true, '': true, '″': true, '‴': true,
'': true, '‶': true, '‷': true, '‸': true, '': true, '': true,
'※': true,
// Simbol mata uang (hanya yang umum)
'€': true, '₹': true,
// Karakter lain yang mungkin diperlukan
}
// Filter karakter menggunakan buffer pool
for _, r := range resp {
if r < 128 || allowedChars[r] {
buf.WriteRune(r)
}
}
// Trim whitespace
result := strings.TrimSpace(buf.String())
return result
}
// extractCode extracts the code field from metaData using reflection
// func extractCode(metaData interface{}) interface{} {
// v := reflect.ValueOf(metaData)
// switch v.Kind() {
// case reflect.Struct:
// codeField := v.FieldByName("Code")
// if codeField.IsValid() {
// return codeField.Interface()
// }
// case reflect.Map:
// if m, ok := metaData.(map[string]interface{}); ok {
// return m["code"]
// }
// case reflect.String:
// var metaMap map[string]interface{}
// if err := json.Unmarshal([]byte(metaData.(string)), &metaMap); err == nil {
// return metaMap["code"]
// }
// }
// return nil
// }
// parseHTTPStatusCode extracts HTTP status code from error message
func parseHTTPStatusCode(errMsg string) int {
if strings.Contains(errMsg, "HTTP error:") {
parts := strings.Split(errMsg, "HTTP error:")
if len(parts) > 1 {
statusPart := strings.TrimSpace(parts[1])
if statusCode, err := strconv.Atoi(strings.Fields(statusPart)[0]); err == nil {
return statusCode
}
}
}
return 500 // Default to internal server error
}
func (h *PesertaHandler) isValidJSON(str string) bool {
var js interface{}
return json.Unmarshal([]byte(str), &js) == nil
}
// GetBynik godoc
// @Summary Get Bynik data
// @Description Get participant eligibility information by NIK
// @Tags Peserta
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param X-Request-ID header string false "Request ID for tracking"
// @Param nik path string true "nik" example("example_value")
// @Success 200 {object} peserta.PesertaResponse "Successfully retrieved Bynik data"
// @Failure 400 {object} models.ErrorResponseBpjs "Bad request - invalid parameters"
// @Failure 401 {object} models.ErrorResponseBpjs "Unauthorized - invalid API credentials"
// @Failure 404 {object} models.ErrorResponseBpjs "Not found - Bynik not found"
// @Failure 500 {object} models.ErrorResponseBpjs "Internal server error"
// @Router /Peserta/nik/:nik [get]
func (h *PesertaHandler) GetBynik(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
// 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 database connection
dbConn, err := h.db.GetDB("postgres_satudata")
if err != nil {
h.logger.Error("Database connection failed", map[string]interface{}{
"error": err.Error(),
"request_id": requestID,
})
c.JSON(http.StatusInternalServerError, models.ErrorResponseBpjs{
Status: "error",
Message: "Database connection failed",
RequestID: requestID,
})
return
}
// Note: dbConn is available for future database operations (e.g., caching, logging)
_ = dbConn // Prevent unused variable warning
// Context Paramaeter
now := time.Now()
dateStr := now.Format("2006-01-02")
fmt.Println("Date (YYYY-MM-DD):", dateStr)
h.logger.Info("Processing GetBynik request", map[string]interface{}{
"request_id": requestID,
"endpoint": "/Peserta/nik/:nik/tglSEP/" + dateStr,
"nik": c.Param("nik"),
})
// Extract path parameters
nik := c.Param("nik")
if nik == "" || nik == ":nik" {
h.logger.Error("Missing required parameter nik", map[string]interface{}{
"request_id": requestID,
})
c.JSON(http.StatusBadRequest, models.ErrorResponseBpjs{
Status: "error",
Message: "Parameter NIK Masih Kosong / Isi Dahulu NIK!",
RequestID: requestID,
})
return
}
var response peserta.PesertaResponse
endpoint := "/Peserta/nik/:nik/tglSEP/" + dateStr
endpoint = strings.Replace(endpoint, ":nik", nik, 1)
resp, err := h.service.GetRawResponse(ctx, endpoint)
if err != nil {
// Check if error message contains 404 status code
if strings.Contains(err.Error(), "HTTP error: 404") {
h.logger.Error("Bynik not found", map[string]interface{}{
"error": err.Error(),
"request_id": requestID,
})
c.JSON(http.StatusNotFound, models.ErrorResponseBpjs{
Status: "error",
Message: "Bynik not found",
RequestID: requestID,
})
return
}
h.logger.Error("Failed to get Bynik", map[string]interface{}{
"error": err.Error(),
"request_id": requestID,
})
c.JSON(http.StatusInternalServerError, models.ErrorResponseBpjs{
Status: "error",
Message: "Internal server error",
RequestID: requestID,
})
return
}
// Map the raw response
response.MetaData = resp.MetaData
if resp.Response != nil {
response.Data = &peserta.PesertaData{}
if respStr, ok := resp.Response.(string); ok {
// Decrypt the response string
consID, secretKey, _, tstamp, _ := h.config.SetHeader()
decryptedResp, err := services.ResponseVclaim(respStr, consID+secretKey+tstamp)
if err != nil {
h.logger.Error("Failed to decrypt response", map[string]interface{}{
"error": err.Error(),
"request_id": requestID,
})
} else {
// Clean the decrypted response
cleanedResp := cleanResponse(decryptedResp)
if h.isValidJSON(cleanedResp) {
// Unmarshal kembali setelah dibersihkan
err = json.Unmarshal([]byte(cleanedResp), response.Data)
if err != nil {
h.logger.Warn("Failed to unmarshal decrypted response", map[string]interface{}{
"error": err.Error(),
"request_id": requestID,
"response_preview": cleanedResp[:min(100, len(cleanedResp))], // Log first 100 chars for debugging
})
// Set Data to nil if unmarshal fails to avoid sending empty struct
response.Data = nil
}
} else {
h.logger.Warn("Invalid JSON in data, storing as string", map[string]interface{}{
"request_id": requestID,
"response": cleanedResp,
})
response.Data.RawResponse = cleanedResp
}
}
} else if respMap, ok := resp.Response.(map[string]interface{}); ok {
// Response is already unmarshaled JSON
if dataMap, exists := respMap["peserta"]; exists {
dataBytes, _ := json.Marshal(dataMap)
json.Unmarshal(dataBytes, response.Data)
} else {
// Try to unmarshal the whole response
respBytes, _ := json.Marshal(resp.Response)
json.Unmarshal(respBytes, response.Data)
}
}
}
// Ensure response has proper fields
response.Status = "success"
response.RequestID = requestID
// Ambil status code dari metaData.code
var statusCode int
code := models.ExtractCode(response.MetaData)
if code != nil {
statusCode = models.GetStatusCodeFromMeta(code)
} else {
statusCode = 200
}
c.JSON(statusCode, response)
}
// GetBynokartu godoc
// @Summary Get Bynokartu data
// @Description Get participant eligibility information by card number
// @Tags Peserta
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param X-Request-ID header string false "Request ID for tracking"
// @Param nokartu path string true "nokartu" example("example_value")
// @Success 200 {object} peserta.PesertaResponse "Successfully retrieved Bynokartu data"
// @Failure 400 {object} models.ErrorResponseBpjs "Bad request - invalid parameters"
// @Failure 401 {object} models.ErrorResponseBpjs "Unauthorized - invalid API credentials"
// @Failure 404 {object} models.ErrorResponseBpjs "Not found - Bynokartu not found"
// @Failure 500 {object} models.ErrorResponseBpjs "Internal server error"
// @Router /Peserta/nokartu/:nokartu [get]
func (h *PesertaHandler) GetBynokartu(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
// 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 database connection
dbConn, err := h.db.GetDB("postgres_satudata")
if err != nil {
h.logger.Error("Database connection failed", map[string]interface{}{
"error": err.Error(),
"request_id": requestID,
})
c.JSON(http.StatusInternalServerError, models.ErrorResponseBpjs{
Status: "error",
Message: "Database connection failed",
RequestID: requestID,
})
return
}
// Note: dbConn is available for future database operations (e.g., caching, logging)
_ = dbConn // Prevent unused variable warning
// Context Paramaeter
now := time.Now()
dateStr := now.Format("2006-01-02")
fmt.Println("Date (YYYY-MM-DD):", dateStr)
h.logger.Info("Processing GetBynokartu request", map[string]interface{}{
"request_id": requestID,
"endpoint": "/Peserta/nokartu/:nokartu/tglSEP/" + dateStr,
"nik": c.Param("nokartu"),
})
// Extract path parameters
nokartu := c.Param("nokartu")
if nokartu == "" || nokartu == ":nokartu" {
h.logger.Error("Missing required parameter nokartu", map[string]interface{}{
"request_id": requestID,
})
c.JSON(http.StatusBadRequest, models.ErrorResponseBpjs{
Status: "error",
Message: "Parameter Nomor Kartu Bpjs Masih Kosong / Isi Dahulu Nomor Kartu!",
RequestID: requestID,
})
return
}
var response peserta.PesertaResponse
endpoint := "/Peserta/nokartu/:nokartu/tglSEP/" + dateStr
endpoint = strings.Replace(endpoint, ":nokartu", nokartu, 1)
resp, err := h.service.GetRawResponse(ctx, endpoint)
if err != nil {
// Check if error message contains 404 status code
if strings.Contains(err.Error(), "HTTP error: 404") {
h.logger.Error("ByNoKartu not found", map[string]interface{}{
"error": err.Error(),
"request_id": requestID,
})
c.JSON(http.StatusNotFound, models.ErrorResponseBpjs{
Status: "error",
Message: "ByNoKartu not found",
RequestID: requestID,
})
return
}
h.logger.Error("Failed to get ByNoKartu", map[string]interface{}{
"error": err.Error(),
"request_id": requestID,
})
c.JSON(http.StatusInternalServerError, models.ErrorResponseBpjs{
Status: "error",
Message: "Internal server error",
RequestID: requestID,
})
return
}
// Map the raw response
response.MetaData = resp.MetaData
if resp.Response != nil {
response.Data = &peserta.PesertaData{}
if respStr, ok := resp.Response.(string); ok {
// Decrypt the response string
consID, secretKey, _, tstamp, _ := h.config.SetHeader()
decryptedResp, err := services.ResponseVclaim(respStr, consID+secretKey+tstamp)
if err != nil {
h.logger.Error("Failed to decrypt response", map[string]interface{}{
"error": err.Error(),
"request_id": requestID,
})
} else {
// Clean the decrypted response
cleanedResp := cleanResponse(decryptedResp)
err = json.Unmarshal([]byte(cleanedResp), response.Data)
if err != nil {
h.logger.Warn("Failed to unmarshal decrypted response", map[string]interface{}{
"error": err.Error(),
"request_id": requestID,
"response_preview": cleanedResp[:min(100, len(cleanedResp))], // Log first 100 chars for debugging
})
// Set Data to nil if unmarshal fails to avoid sending empty struct
response.Data = nil
}
}
} else if respMap, ok := resp.Response.(map[string]interface{}); ok {
// Response is already unmarshaled JSON
if dataMap, exists := respMap["peserta"]; exists {
dataBytes, _ := json.Marshal(dataMap)
json.Unmarshal(dataBytes, response.Data)
} else {
// Try to unmarshal the whole response
respBytes, _ := json.Marshal(resp.Response)
json.Unmarshal(respBytes, response.Data)
}
}
}
// Ensure response has proper fields
response.Status = "success"
response.RequestID = requestID
// Ambil status code dari metaData.code
var statusCode int
code := models.ExtractCode(response.MetaData)
if code != nil {
statusCode = models.GetStatusCodeFromMeta(code)
} else {
statusCode = 200
}
c.JSON(statusCode, response)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
package antreanbpjs
+30 -46
View File
@@ -1,19 +1,22 @@
package v1
import (
AplicareHandler "api-service/internal/aplicare"
"api-service/internal/config"
"api-service/internal/database"
"api-service/internal/handlers/antreanbpjs"
authHandlers "api-service/internal/handlers/auth"
healthcheckHandlers "api-service/internal/handlers/healthcheck"
pesertaHandlers "api-service/internal/handlers/peserta"
retribusiHandlers "api-service/internal/handlers/retribusi"
"api-service/internal/middleware"
services "api-service/internal/services/auth"
"api-service/pkg/logger"
"context"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
@@ -111,37 +114,35 @@ func RegisterRoutes(cfg *config.Config) *gin.Engine {
// PUBLISHED ROUTES
// =============================================================================
// Participant eligibility information (peserta) routes
pesertaHandler := pesertaHandlers.NewPesertaHandler(pesertaHandlers.PesertaHandlerConfig{
// Retribusi endpoints with WebSocket notifications
antreanbpjsHandler := antreanbpjs.NewVClaimHandler(antreanbpjs.VClaimHandlerConfig{
Config: cfg,
Logger: *logger.Default(),
Validator: validator.New(),
Validator: nil,
})
pesertaGroup := v1.Group("/peserta")
pesertaGroup.GET("/nokartu/:nokartu", pesertaHandler.GetBynokartu)
pesertaGroup.GET("/nik/:nik", pesertaHandler.GetBynik)
antreanbpjsGroup := v1.Group("/poli")
antreanbpjsGroup.GET("/ketersediaan", antreanbpjsHandler.GetPoli)
antreanbpjsGroup.GET("/apapun", antreanbpjsHandler.Gethealth)
// Retribusi endpoints with WebSocket notifications
retribusiHandler := retribusiHandlers.NewRetribusiHandler()
retribusiGroup := v1.Group("/retribusi")
aplicaresHandler := AplicareHandler.NewAplicaresHandler(AplicareHandler.AplicaresHandlerConfig{
Config: cfg,
Logger: *logger.Default(),
Validator: nil,
})
// Start background scheduler — berhenti otomatis saat app shutdown
ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
aplicaresHandler.StartScheduler(ctx)
ag := v1.Group("/aplicares")
{
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)
})
retribusiGroup.PUT("/id/:id", func(c *gin.Context) {
retribusiHandler.UpdateRetribusi(c)
})
retribusiGroup.DELETE("/id/:id", func(c *gin.Context) {
retribusiHandler.DeleteRetribusi(c)
})
ag.GET("/beds", aplicaresHandler.GetBeds)
ag.GET("/state", aplicaresHandler.GetState)
ag.POST("/sync", aplicaresHandler.TriggerSync)
ag.GET("/check-bpjs", aplicaresHandler.CheckBPJS)
ag.GET("/ref/kelas", aplicaresHandler.GetRefKelas)
ag.GET("/logs", aplicaresHandler.GetSyncLogs)
}
// =============================================================================
@@ -151,23 +152,6 @@ func RegisterRoutes(cfg *config.Config) *gin.Engine {
protected := v1.Group("/")
protected.Use(middleware.ConfigurableAuthMiddleware(cfg))
// Protected retribusi endpoints (Authentication Required)
protectedRetribusiGroup := protected.Group("/retribusi")
{
protectedRetribusiGroup.GET("", retribusiHandler.GetRetribusi)
protectedRetribusiGroup.GET("/dynamic", retribusiHandler.GetRetribusiDynamic)
protectedRetribusiGroup.GET("/search", retribusiHandler.SearchRetribusiAdvanced)
protectedRetribusiGroup.GET("/id/:id", retribusiHandler.GetRetribusiByID)
protectedRetribusiGroup.POST("", func(c *gin.Context) {
retribusiHandler.CreateRetribusi(c)
})
protectedRetribusiGroup.PUT("/id/:id", func(c *gin.Context) {
retribusiHandler.UpdateRetribusi(c)
})
protectedRetribusiGroup.DELETE("/id/:id", func(c *gin.Context) {
retribusiHandler.DeleteRetribusi(c)
})
}
return router
}
+100 -83
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"unicode"
@@ -39,20 +40,20 @@ type Service struct {
}
// Response structures
// Gunakan di struct
type MetadataStruct struct {
Code json.Number `json:"code"`
Message string `json:"message"`
}
type ResponMentahDTOVclaim struct {
MetaData struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"metaData"`
Response string `json:"response"`
Metadata MetadataStruct `json:"metadata"`
Response interface{} `json:"response"`
}
type ResponDTOVclaim struct {
MetaData struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"metaData"`
Response interface{} `json:"response"`
Metadata MetadataStruct `json:"metadata"`
Response interface{} `json:"response"`
}
// NewService creates a new VClaim service instance
@@ -62,21 +63,33 @@ func NewService(cfg config.BpjsConfig) VClaimService {
Dur("timeout", cfg.Timeout).
Msg("Creating new VClaim service instance")
// Custom transport dengan konfigurasi lebih agresif
transport := &http.Transport{
TLSHandshakeTimeout: 15 * time.Second, // Timeout untuk SSL handshake
ResponseHeaderTimeout: 30 * time.Second, // Timeout menunggu response header
ExpectContinueTimeout: 2 * time.Second,
IdleConnTimeout: 90 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
DisableKeepAlives: false, // Aktifkan keep-alive
ForceAttemptHTTP2: true, // Coba gunakan HTTP/2
}
service := &Service{
config: cfg,
httpClient: &http.Client{
Timeout: cfg.Timeout,
Timeout: cfg.Timeout, // Total timeout (default 30s)
Transport: transport,
},
}
return service
}
// NewServiceFromConfig creates service from main config
func NewServiceFromConfig(cfg *config.Config) VClaimService {
return NewService(cfg.Bpjs)
}
// NewServiceFromInterface creates service from interface (for backward compatibility)
func NewServiceFromInterface(cfg interface{}) (VClaimService, error) {
var bpjsConfig config.BpjsConfig
@@ -93,12 +106,10 @@ func NewServiceFromInterface(cfg interface{}) (VClaimService, error) {
return NewService(bpjsConfig), nil
}
// SetHTTPClient allows custom http client configuration
func (s *Service) SetHTTPClient(client *http.Client) {
s.httpClient = client
}
// prepareRequest prepares HTTP request with required headers
func (s *Service) prepareRequest(ctx context.Context, method, endpoint string, body io.Reader) (*http.Request, string, string, string, string, error) {
fullURL := s.config.BaseURL + endpoint
@@ -141,7 +152,6 @@ func (s *Service) prepareRequest(ctx context.Context, method, endpoint string, b
// processResponse processes response from VClaim API
func (s *Service) processResponse(res *http.Response, consID, secretKey, tstamp string) (*ResponDTOVclaim, error) {
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
@@ -151,78 +161,46 @@ func (s *Service) processResponse(res *http.Response, consID, secretKey, tstamp
return nil, fmt.Errorf("HTTP error: %d - %s", res.StatusCode, string(body))
}
// Parse raw response
var respMentah ResponMentahDTOVclaim
if err := json.Unmarshal(body, &respMentah); err != nil {
return nil, fmt.Errorf("failed to unmarshal raw response: %w", err)
}
// Create final response
finalResp := &ResponDTOVclaim{
MetaData: respMentah.MetaData,
Metadata: respMentah.Metadata,
}
// Check if response needs decryption
if respMentah.Response == "" {
return finalResp, nil
}
// Try to parse as JSON first (unencrypted response)
var tempResp interface{}
if json.Unmarshal([]byte(respMentah.Response), &tempResp) == nil {
finalResp.Response = tempResp
return finalResp, nil
}
// Check if response looks like HTML or error message (don't try to decrypt)
if strings.HasPrefix(respMentah.Response, "<") || strings.Contains(respMentah.Response, "error") {
finalResp.Response = respMentah.Response
return finalResp, nil
}
// Decrypt response using the same timestamp from the request
decryptionKey := consID + secretKey + tstamp
log.Debug().
Str("consID", consID).
Str("tstamp", tstamp).
Int("key_length", len(decryptionKey)).
Msg("Decryption key components")
respDecrypt, err := ResponseVclaim(respMentah.Response, decryptionKey)
if err != nil {
log.Error().Err(err).Msg("Failed to decrypt response")
return nil, fmt.Errorf("failed to decrypt response: %w", err)
}
// Try to unmarshal decrypted response as JSON
if respDecrypt != "" {
// Clean the decrypted response
respDecrypt = cleanResponse(respDecrypt)
// Try multiple cleaning strategies
cleaningStrategies := []string{
respDecrypt,
strings.TrimLeft(respDecrypt, "\ufeff\xfe\xef\xbb\xbf"),
strings.TrimLeftFunc(respDecrypt, func(r rune) bool { return r < 32 && r != '\n' && r != '\r' && r != '\t' }),
// Check tipe response
switch v := respMentah.Response.(type) {
case string:
// Response berupa string (mungkin encrypted)
if v == "" {
return finalResp, nil
}
var jsonParseSuccess bool
for i, cleaned := range cleaningStrategies {
if err := json.Unmarshal([]byte(cleaned), &finalResp.Response); err == nil {
log.Info().
Int("strategy", i+1).
Msg("Successfully parsed JSON with cleaning strategy")
jsonParseSuccess = true
break
}
// Coba decrypt jika terenkripsi
decryptionKey := consID + secretKey + tstamp
respDecrypt, err := ResponseVclaim(v, decryptionKey)
if err != nil {
log.Error().Err(err).Msg("Failed to decrypt response")
finalResp.Response = v // Simpan string asli jika gagal decrypt
return finalResp, nil
}
if !jsonParseSuccess {
// If all JSON parsing fails, store as string
log.Warn().Msg("All JSON parsing strategies failed, storing as string")
// Parse hasil decrypt
var tempResp interface{}
if json.Unmarshal([]byte(respDecrypt), &tempResp) == nil {
finalResp.Response = tempResp
} else {
finalResp.Response = respDecrypt
}
case map[string]interface{}, []interface{}:
// Response sudah berupa object/array (tidak terenkripsi)
finalResp.Response = v
default:
finalResp.Response = v
}
return finalResp, nil
@@ -324,17 +302,36 @@ func (s *Service) Patch(ctx context.Context, endpoint string, payload interface{
// GetRawResponse returns raw response without mapping
func (s *Service) GetRawResponse(ctx context.Context, endpoint string) (*ResponDTOVclaim, error) {
req, consID, secretKey, tstamp, _, err := s.prepareRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
maxRetries := 3
var lastErr error
for attempt := 1; attempt <= maxRetries; attempt++ {
req, consID, secretKey, tstamp, _, err := s.prepareRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
res, err := s.httpClient.Do(req)
if err != nil {
lastErr = err
log.Warn().
Int("attempt", attempt).
Int("max_retries", maxRetries).
Err(err).
Msg("Request failed, retrying...")
// Tunggu sebelum retry (exponential backoff)
if attempt < maxRetries {
time.Sleep(time.Duration(attempt) * time.Second)
continue
}
return nil, fmt.Errorf("failed to execute GET request after %d attempts: %w", maxRetries, lastErr)
}
return s.processResponse(res, consID, secretKey, tstamp)
}
res, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute GET request: %w", err)
}
return s.processResponse(res, consID, secretKey, tstamp)
return nil, lastErr
}
// PostRawResponse returns raw response without mapping
@@ -562,3 +559,23 @@ func findMatchingBrace(s string) int {
return -1
}
type FlexibleCode string
func (fc *FlexibleCode) UnmarshalJSON(data []byte) error {
// Coba unmarshal sebagai string
var s string
if err := json.Unmarshal(data, &s); err == nil {
*fc = FlexibleCode(s)
return nil
}
// Coba unmarshal sebagai number
var n int
if err := json.Unmarshal(data, &n); err == nil {
*fc = FlexibleCode(strconv.Itoa(n))
return nil
}
return fmt.Errorf("code must be string or number")
}