first commit
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
package bpjs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"service/internal/infrastructure/config"
|
||||
"service/pkg/logger"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BpjsClient adalah core HTTP client untuk API BPJS.
|
||||
type BpjsClient interface {
|
||||
DoRequest(ctx context.Context, method, endpoint string, body interface{}) ([]byte, error)
|
||||
}
|
||||
|
||||
type client struct {
|
||||
cfg config.BpjsConfig
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewBpjsClient membuat instance baru dari BpjsClient.
|
||||
func NewBpjsClient(cfg config.BpjsConfig) BpjsClient {
|
||||
return &client{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{Timeout: cfg.Timeout},
|
||||
}
|
||||
}
|
||||
|
||||
// DoRequest secara otomatis menangani header signature, request, pengecekan error dari BPJS, dan dekripsi respons.
|
||||
func (c *client) DoRequest(ctx context.Context, method, endpoint string, body interface{}) ([]byte, error) {
|
||||
if !c.cfg.Enabled {
|
||||
return nil, fmt.Errorf("BPJS VClaim integration is disabled in configuration")
|
||||
}
|
||||
|
||||
baseURL := strings.TrimRight(c.cfg.BaseURL, "/")
|
||||
serviceName := strings.Trim(c.cfg.ServiceName, "/")
|
||||
endpointPath := strings.TrimLeft(endpoint, "/")
|
||||
|
||||
var url string
|
||||
if serviceName != "" {
|
||||
url = fmt.Sprintf("%s/%s/%s", baseURL, serviceName, endpointPath)
|
||||
} else {
|
||||
url = fmt.Sprintf("%s/%s", baseURL, endpointPath)
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
bodyReader = bytes.NewBuffer(jsonBody)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Gunakan method dari config Anda untuk generate header secara otomatis
|
||||
consID, secretKey, userKey, timestamp, signature := c.cfg.SetHeader()
|
||||
|
||||
req.Header.Set("X-cons-id", consID)
|
||||
req.Header.Set("X-timestamp", timestamp)
|
||||
req.Header.Set("X-signature", signature)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
// Aplicares tidak membutuhkan user_key, kirimkan hanya jika tidak kosong (VClaim/Antrol)
|
||||
if userKey != "" {
|
||||
req.Header.Set("user_key", userKey)
|
||||
}
|
||||
|
||||
// Aplicares menggunakan application/json, sementara layanan lain menggunakan x-www-form-urlencoded
|
||||
if strings.Contains(strings.ToLower(c.cfg.ServiceName), "aplicaresws") {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req.Header.Set("Content-Type", "Application/x-www-form-urlencoded")
|
||||
}
|
||||
|
||||
// Injeksi Token Khusus Antrean RS (x-token) via Context
|
||||
if token, ok := ctx.Value(TokenContextKey).(string); ok && token != "" {
|
||||
req.Header.Set("x-token", token)
|
||||
}
|
||||
|
||||
// [RADAR] LOG OUTGOING REQUEST
|
||||
logger.Default().Info("🛫 BPJS API Request Sent",
|
||||
logger.String("target_url", url),
|
||||
logger.String("method", method),
|
||||
logger.String("used_cons_id", consID),
|
||||
logger.String("used_user_key", userKey),
|
||||
logger.String("used_x_token", req.Header.Get("x-token")),
|
||||
logger.String("timestamp", timestamp),
|
||||
)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// [RADAR] LOG INCOMING RESPONSE
|
||||
// logger.Default().Info("🛬 BPJS API Response Received",
|
||||
// logger.Int("status_code", resp.StatusCode),
|
||||
// logger.String("raw_body", strings.TrimSpace(string(respBody))),
|
||||
// )
|
||||
|
||||
// 1. Tangani penolakan API Gateway secara langsung (misal: 401/403 dengan balikan plaintext "Authentication failed")
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("BPJS Gateway Error (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
|
||||
// 2. Tangani kasus BPJS mengembalikan HTTP 200 OK tetapi body berupa Plain Text (misal "Authentication failed")
|
||||
bodyStr := strings.TrimSpace(string(respBody))
|
||||
if !strings.HasPrefix(bodyStr, "{") && !strings.HasPrefix(bodyStr, "[") {
|
||||
logger.Default().Error("BPJS Non-JSON Response", logger.String("body", bodyStr), logger.String("endpoint", endpointPath))
|
||||
return nil, fmt.Errorf("BPJS API Error (Plain Text): %s", bodyStr)
|
||||
}
|
||||
|
||||
// 3. Parsing struktur dasar BPJS Response secara fleksibel
|
||||
var baseResp struct {
|
||||
MetaData struct {
|
||||
Code interface{} `json:"code"` // Gunakan interface{} krn VClaim mereturn string "200", Antrol mereturn int 200
|
||||
Message string `json:"message"`
|
||||
} `json:"metaData"`
|
||||
Metadata struct { // Antrean RS (Antrol) menggunakan key dengan huruf kecil
|
||||
Code interface{} `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"metadata"`
|
||||
Response json.RawMessage `json:"response"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &baseResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse base BPJS response: %w. Body: %s", err, string(respBody))
|
||||
}
|
||||
|
||||
// 4. Ekstrak code dan message secara dinamis untuk format VClaim dan Antrean RS
|
||||
var codeStr, message string
|
||||
if baseResp.MetaData.Code != nil {
|
||||
codeStr = fmt.Sprintf("%v", baseResp.MetaData.Code)
|
||||
message = baseResp.MetaData.Message
|
||||
} else if baseResp.Metadata.Code != nil {
|
||||
codeStr = fmt.Sprintf("%v", baseResp.Metadata.Code)
|
||||
message = baseResp.Metadata.Message
|
||||
}
|
||||
|
||||
// Kode sukses BPJS biasanya 200, 201, atau terkadang 1
|
||||
if codeStr != "200" && codeStr != "1" && codeStr != "201" {
|
||||
logger.Default().Warn("BPJS API Error", logger.String("code", codeStr), logger.String("message", message))
|
||||
return nil, fmt.Errorf("BPJS error %s: %s", codeStr, message)
|
||||
}
|
||||
|
||||
// Jika "response" null / kosong, berarti sukses tanpa payload
|
||||
if len(baseResp.Response) == 0 || string(baseResp.Response) == "null" {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
|
||||
// Dekripsi response (VClaim V2)
|
||||
var cipherText string
|
||||
if err := json.Unmarshal(baseResp.Response, &cipherText); err != nil {
|
||||
// Jika response bukan string terenkripsi (misal: response sukses tanpa data)
|
||||
return baseResp.Response, nil
|
||||
}
|
||||
|
||||
// Lakukan dekripsi
|
||||
decryptedStr, err := DecryptPayload(cipherText, consID, secretKey, timestamp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt BPJS payload: %w", err)
|
||||
}
|
||||
|
||||
return []byte(decryptedStr), nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package bpjs
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// DecryptPayload mengembalikan payload BPJS API yang sudah didekripsi dan disalurkan ke mesin dekompresi otomatis.
|
||||
func DecryptPayload(cipherText string, consID, secretKey, timestamp string) (string, error) {
|
||||
if cipherText == "" {
|
||||
return "", errors.New("ciphertext cannot be empty")
|
||||
}
|
||||
|
||||
// 1. Generate Key dari gabungan ConsID + SecretKey + Timestamp
|
||||
keyString := consID + secretKey + timestamp
|
||||
hash := sha256.Sum256([]byte(keyString))
|
||||
key := hash[:]
|
||||
|
||||
// IV adalah 16 byte pertama dari key
|
||||
iv := key[:16]
|
||||
|
||||
// 2. Base64 Decode CipherText
|
||||
cipherBytes, err := base64.StdEncoding.DecodeString(cipherText)
|
||||
if err != nil {
|
||||
return "", errors.New("failed to base64 decode cipher text")
|
||||
}
|
||||
|
||||
// 3. AES-256-CBC Decryption
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(cipherBytes)%aes.BlockSize != 0 {
|
||||
return "", errors.New("ciphertext is not a multiple of the block size")
|
||||
}
|
||||
|
||||
mode := cipher.NewCBCDecrypter(block, iv)
|
||||
mode.CryptBlocks(cipherBytes, cipherBytes)
|
||||
|
||||
// Unpad PKCS7
|
||||
cipherBytes = unpadPKCS7(cipherBytes)
|
||||
|
||||
// 4. Automatis Dekompresi menggunakan Advanced Handler (LZString/Gzip)
|
||||
return DecompressPayload(cipherBytes)
|
||||
}
|
||||
|
||||
func unpadPKCS7(b []byte) []byte {
|
||||
length := len(b)
|
||||
if length == 0 {
|
||||
return b
|
||||
}
|
||||
unpadding := int(b[length-1])
|
||||
return b[:(length - unpadding)]
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package bpjs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
lzstring "github.com/daku10/go-lz-string"
|
||||
)
|
||||
|
||||
// DecompressPayload mengupayakan ekstraksi JSON menggunakan berbagai algoritma (GZIP, LZString, dll).
|
||||
func DecompressPayload(data []byte) (string, error) {
|
||||
log.Printf("DecompressPayload: Attempting decompression, data length: %d", len(data))
|
||||
|
||||
// Log hex dump for better debugging
|
||||
hexDump := make([]string, min(32, len(data)))
|
||||
for i := 0; i < len(hexDump); i++ {
|
||||
hexDump[i] = fmt.Sprintf("%02x", data[i])
|
||||
}
|
||||
log.Printf("DecompressPayload: Hex dump (first 32 bytes): %s", strings.Join(hexDump, " "))
|
||||
|
||||
// Method 1: Try LZ-string first (most common for BPJS)
|
||||
if result, err := tryLZStringMethods(data); err == nil {
|
||||
log.Println("DecompressPayload: LZ-string decompression successful")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Method 2: Try gzip
|
||||
if result, err := tryGzipDecompression(data); err == nil && isValidDecompressedResult(result) {
|
||||
log.Println("DecompressPayload: Gzip decompression successful")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Method 3: Try as plain text
|
||||
if isValidUTF8AndPrintable(string(data)) {
|
||||
result := string(data)
|
||||
if isValidDecompressedResult(result) {
|
||||
log.Println("DecompressPayload: Data is already valid text")
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Method 4: Try base64 decode then decompress
|
||||
if result, err := tryBase64ThenDecompress(data); err == nil {
|
||||
log.Println("DecompressPayload: Base64 then decompress successful")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
log.Printf("DecompressPayload: All methods failed")
|
||||
return "", errors.New("all decompression methods failed")
|
||||
}
|
||||
|
||||
// tryLZStringMethods attempts LZ-string decompression
|
||||
func tryLZStringMethods(data []byte) (string, error) {
|
||||
dataStr := string(data)
|
||||
log.Printf("tryLZStringMethods: Raw data length: %d", len(dataStr))
|
||||
|
||||
// Method 1: Clean corrupt prefix and find LZ-string pattern
|
||||
cleanedData := extractCleanLZString(dataStr)
|
||||
if cleanedData != "" {
|
||||
log.Printf("tryLZStringMethods: Found clean LZ-string: %s", cleanedData[:min(50, len(cleanedData))])
|
||||
|
||||
// Decompress according to BPJS standards
|
||||
if result, err := lzstring.DecompressFromEncodedURIComponent(cleanedData); err == nil && len(result) > 0 {
|
||||
if isValidDecompressedResult(result) {
|
||||
log.Printf("LZ-string decompression successful, length: %d", len(result))
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Fallback direct decompression
|
||||
if result, err := lzstring.DecompressFromEncodedURIComponent(dataStr); err == nil && len(result) > 0 {
|
||||
if isValidDecompressedResult(result) {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: Try base64 LZ-string
|
||||
if result, err := lzstring.DecompressFromBase64(dataStr); err == nil && len(result) > 0 {
|
||||
if isValidDecompressedResult(result) {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("LZ-string decompression failed")
|
||||
}
|
||||
|
||||
// extractCleanLZString extracts clean LZ-string from corrupt data
|
||||
func extractCleanLZString(data string) string {
|
||||
// Common LZ-string patterns from BPJS documentation
|
||||
patterns := []string{"EAuUA", "N4Ig", "BwIw", "CwIw", "DwIw", "EwIw", "FwIw", "GwIw", "HwIw"}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
if idx := strings.Index(data, pattern); idx >= 0 {
|
||||
// Extract from pattern to end
|
||||
candidate := data[idx:]
|
||||
log.Printf("extractCleanLZString: Found pattern '%s' at position %d", pattern, idx)
|
||||
|
||||
// Clean only valid base64 characters
|
||||
cleaned := extractBase64Only(candidate)
|
||||
if len(cleaned) > 100 { // Minimum length for valid data
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractBase64Only extracts only base64 valid characters
|
||||
func extractBase64Only(s string) string {
|
||||
base64Chars := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
|
||||
var result strings.Builder
|
||||
|
||||
for _, char := range s {
|
||||
if strings.ContainsRune(base64Chars, char) {
|
||||
result.WriteRune(char)
|
||||
} else {
|
||||
// Stop at non-base64 character if already long enough
|
||||
if result.Len() > 100 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// tryGzipDecompression attempts gzip decompression
|
||||
func tryGzipDecompression(data []byte) (string, error) {
|
||||
reader, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
decompressed, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(decompressed), nil
|
||||
}
|
||||
|
||||
// tryBase64ThenDecompress attempts base64 decode then decompress
|
||||
func tryBase64ThenDecompress(data []byte) (string, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(string(data))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return tryLZStringMethods(decoded)
|
||||
}
|
||||
|
||||
// isValidDecompressedResult validates decompressed result
|
||||
func isValidDecompressedResult(result string) bool {
|
||||
if len(result) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Trim whitespace and check UTF-8
|
||||
trimmed := strings.TrimSpace(result)
|
||||
if !utf8.ValidString(trimmed) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must start with { or [ for JSON
|
||||
if len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '[') {
|
||||
// Validate as JSON
|
||||
var js json.RawMessage
|
||||
if json.Unmarshal([]byte(result), &js) == nil {
|
||||
log.Printf("Decompressed result is valid JSON, length: %d", len(result))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// If not JSON, reject
|
||||
log.Printf("Decompressed result is not valid JSON")
|
||||
return false
|
||||
}
|
||||
|
||||
// isValidUTF8AndPrintable checks if string is valid UTF-8 and printable
|
||||
func isValidUTF8AndPrintable(s string) bool {
|
||||
if !utf8.ValidString(s) {
|
||||
log.Printf("isValidUTF8AndPrintable: String is not valid UTF-8")
|
||||
return false
|
||||
}
|
||||
|
||||
// Count valid characters
|
||||
validChars := 0
|
||||
totalChars := 0
|
||||
|
||||
for _, r := range s {
|
||||
totalChars++
|
||||
|
||||
if r >= 32 && r <= 126 { // Printable ASCII
|
||||
validChars++
|
||||
} else if r == '\n' || r == '\r' || r == '\t' { // Allowed control chars
|
||||
validChars++
|
||||
} else if r >= 160 { // Unicode characters
|
||||
validChars++
|
||||
}
|
||||
}
|
||||
|
||||
validRatio := float64(validChars) / float64(totalChars)
|
||||
log.Printf("isValidUTF8AndPrintable: Valid chars ratio: %.2f (%d/%d)", validRatio, validChars, totalChars)
|
||||
|
||||
// At least 70% should be valid characters
|
||||
return validRatio >= 0.7
|
||||
}
|
||||
|
||||
// hasLZStringPattern detects LZ-string patterns
|
||||
func hasLZStringPattern(s string) bool {
|
||||
if len(s) < 10 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Common LZ-string compressed data patterns
|
||||
commonLZPatterns := []string{
|
||||
"N4Ig", "BwIw", "CwIw", "DwIw", "EwIw", "FwIw", "GwIw", "HwIw",
|
||||
"IwIw", "JwIw", "KwIw", "LwIw", "MwIw", "NwIw", "OwIw", "PwIw",
|
||||
}
|
||||
|
||||
for _, pattern := range commonLZPatterns {
|
||||
if strings.HasPrefix(s, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if string contains only base64 characters without spaces or newlines
|
||||
base64Pattern := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
|
||||
if len(s) > 50 { // Only check long strings
|
||||
invalidChars := 0
|
||||
for _, char := range s {
|
||||
if !strings.ContainsRune(base64Pattern, char) {
|
||||
invalidChars++
|
||||
}
|
||||
}
|
||||
// If less than 5% invalid characters, likely LZ-string
|
||||
if float64(invalidChars)/float64(len(s)) < 0.05 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// cleanResponse cleans response string
|
||||
func cleanResponse(s string) string {
|
||||
// Remove UTF-8 BOM and other BOM variations
|
||||
s = strings.TrimPrefix(s, "\xef\xbb\xbf") // UTF-8 BOM
|
||||
s = strings.TrimPrefix(s, "\ufeff") // Unicode BOM
|
||||
s = strings.TrimPrefix(s, "\ufffe") // Unicode BOM (reverse)
|
||||
s = strings.TrimPrefix(s, "\xff\xfe") // UTF-16 LE BOM
|
||||
s = strings.TrimPrefix(s, "\xfe\xff") // UTF-16 BE BOM
|
||||
|
||||
// Remove control and non-printable characters
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
if r >= 32 && r <= 126 || r == '\n' || r == '\r' || r == '\t' {
|
||||
result.WriteRune(r)
|
||||
} else if r > 126 && unicode.IsPrint(r) {
|
||||
// Allow Unicode printable characters
|
||||
result.WriteRune(r)
|
||||
}
|
||||
// Skip all other characters (including BOM fragments)
|
||||
}
|
||||
|
||||
cleaned := result.String()
|
||||
cleaned = strings.TrimSpace(cleaned)
|
||||
|
||||
// Find and extract valid JSON
|
||||
if idx := strings.Index(cleaned, "{"); idx >= 0 {
|
||||
cleaned = cleaned[idx:]
|
||||
// Find matching closing brace
|
||||
if endIdx := findMatchingBrace(cleaned); endIdx > 0 {
|
||||
cleaned = cleaned[:endIdx+1]
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("cleanResponse: Final cleaned length: %d", len(cleaned))
|
||||
log.Printf("cleanResponse: Final result preview: %s", cleaned[:min(200, len(cleaned))])
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// findMatchingBrace finds matching closing brace
|
||||
func findMatchingBrace(s string) int {
|
||||
if len(s) == 0 || s[0] != '{' {
|
||||
return -1
|
||||
}
|
||||
|
||||
braceCount := 0
|
||||
inString := false
|
||||
escaped := false
|
||||
|
||||
for i, char := range s {
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
|
||||
if char == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
|
||||
if char == '"' && !escaped {
|
||||
inString = !inString
|
||||
continue
|
||||
}
|
||||
|
||||
if !inString {
|
||||
if char == '{' {
|
||||
braceCount++
|
||||
} else if char == '}' {
|
||||
braceCount--
|
||||
if braceCount == 0 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package bpjs
|
||||
|
||||
import (
|
||||
"service/internal/infrastructure/config"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
// Konstanta Base Service Name (Lingkungan Production)
|
||||
ServiceNameAntreanRS = "antreanrs"
|
||||
ServiceNameAntreanFKTP = "antreanfktp"
|
||||
ServiceNameApotek = "apotek-rest"
|
||||
ServiceNamePCare = "pcare-rest"
|
||||
ServiceNameICare = "ihs"
|
||||
ServiceNameERekamMedis = "erekammedis"
|
||||
ServiceNameAplicare = "aplicaresws"
|
||||
ServiceNameVClaim = "vclaim-rest"
|
||||
)
|
||||
|
||||
// Factory menyediakan akses tersentralisasi ke berbagai layanan HTTP Client BPJS yang Fleksibel.
|
||||
type Factory struct {
|
||||
baseConfig config.BpjsConfig
|
||||
}
|
||||
|
||||
// NewBPJSFactory membuat instance factory baru dengan konfigurasi utama.
|
||||
func NewBPJSFactory(cfg config.BpjsConfig) *Factory {
|
||||
return &Factory{
|
||||
baseConfig: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Factory) resolveServiceName(baseName string) string {
|
||||
name := baseName
|
||||
// Auto-detect environment berdasarkan BaseURL (jika URL mengandung kata 'dev')
|
||||
if strings.Contains(f.baseConfig.BaseURL, "dev") {
|
||||
if baseName == ServiceNameVClaim || baseName == ServiceNameApotek || baseName == ServiceNamePCare {
|
||||
name = baseName + "-dev"
|
||||
} else if baseName == ServiceNameAplicare {
|
||||
name = baseName // Pengecualian: Aplicares sama sekali tidak menggunakan akhiran -dev atau _dev
|
||||
} else {
|
||||
name = baseName + "_dev" // AntreanRS, dll menggunakan underscore
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (f *Factory) createClient(serviceName string, customConsID string, customSecretKey string, customUserKey string) BpjsClient {
|
||||
svcConfig := f.baseConfig
|
||||
|
||||
// Timpa kredensial Default dengan kredensial spesifik layanan (Khusus Apotek)
|
||||
if customConsID != "" {
|
||||
svcConfig.ConsID = customConsID
|
||||
}
|
||||
if customSecretKey != "" {
|
||||
svcConfig.SecretKey = customSecretKey
|
||||
}
|
||||
// Timpa Active UserKey
|
||||
svcConfig.UserKey = customUserKey
|
||||
// [HOTFIX] Penyesuaian Base URL khusus untuk Aplicares
|
||||
// Karena Aplicares BPJS (Ketersediaan Tempat Tidur) BELUM dimigrasikan ke API Gateway Kong (apijkn).
|
||||
// Aplicares masih menggunakan server lamanya yaitu dvlp (Dev) dan new-api (Prod).
|
||||
if serviceName == ServiceNameAplicare {
|
||||
if strings.Contains(svcConfig.BaseURL, "apijkn-dev") {
|
||||
svcConfig.BaseURL = "https://dvlp.bpjs-kesehatan.go.id:8888"
|
||||
} else if strings.Contains(svcConfig.BaseURL, "apijkn.bpjs-kesehatan.go.id") {
|
||||
svcConfig.BaseURL = "https://new-api.bpjs-kesehatan.go.id"
|
||||
}
|
||||
}
|
||||
svcConfig.ServiceName = f.resolveServiceName(serviceName)
|
||||
return NewBpjsClient(svcConfig)
|
||||
}
|
||||
|
||||
// --- Kumpulan Instance Client untuk Masing-masing Layanan BPJS ---
|
||||
|
||||
func (f *Factory) VClaim() BpjsClient {
|
||||
// Gunakan "" agar otomatis memakai kredensial Default (VClaim)
|
||||
return f.createClient(ServiceNameVClaim, "", "", f.baseConfig.VclaimUserKey)
|
||||
}
|
||||
|
||||
func (f *Factory) AntreanRS() BpjsClient {
|
||||
return f.createClient(ServiceNameAntreanRS, "", "", f.baseConfig.AntrolUserKey)
|
||||
}
|
||||
|
||||
func (f *Factory) AntreanFKTP() BpjsClient {
|
||||
return f.createClient(ServiceNameAntreanFKTP, "", "", f.baseConfig.AntrolUserKey)
|
||||
}
|
||||
|
||||
func (f *Factory) Aplicare() BpjsClient {
|
||||
return f.createClient(ServiceNameAplicare, "", "", f.baseConfig.AplicareUserKey)
|
||||
}
|
||||
|
||||
func (f *Factory) Apotek() BpjsClient {
|
||||
// Apotek menggunakan ConsID dan SecretKey nya sendiri!
|
||||
return f.createClient(ServiceNameApotek, f.baseConfig.ApotekConsID, f.baseConfig.ApotekSecretKey, f.baseConfig.ApotekUserKey)
|
||||
}
|
||||
|
||||
func (f *Factory) PCare() BpjsClient {
|
||||
return f.createClient(ServiceNamePCare, "", "", "")
|
||||
}
|
||||
|
||||
func (f *Factory) ICare() BpjsClient {
|
||||
return f.createClient(ServiceNameICare, "", "", f.baseConfig.IhsUserKey)
|
||||
}
|
||||
|
||||
func (f *Factory) ERekamMedis() BpjsClient {
|
||||
return f.createClient(ServiceNameERekamMedis, "", "", f.baseConfig.IhsUserKey)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package bpjs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"service/internal/infrastructure/config"
|
||||
)
|
||||
|
||||
// VClaimService interface for VClaim operations
|
||||
type VClaimService interface {
|
||||
// Basic HTTP methods
|
||||
Get(ctx context.Context, endpoint string, result interface{}) error
|
||||
Post(ctx context.Context, endpoint string, payload interface{}, result interface{}) error
|
||||
Put(ctx context.Context, endpoint string, payload interface{}, result interface{}) error
|
||||
Patch(ctx context.Context, endpoint string, payload interface{}, result interface{}) error
|
||||
Delete(ctx context.Context, endpoint string, result interface{}) error
|
||||
|
||||
// Raw response methods
|
||||
GetRawResponse(ctx context.Context, endpoint string) (*ResponseDTO, error)
|
||||
PostRawResponse(ctx context.Context, endpoint string, payload interface{}) (*ResponseDTO, error)
|
||||
PutRawResponse(ctx context.Context, endpoint string, payload interface{}) (*ResponseDTO, error)
|
||||
PatchRawResponse(ctx context.Context, endpoint string, payload interface{}) (*ResponseDTO, error)
|
||||
DeleteRawResponse(ctx context.Context, endpoint string) (*ResponseDTO, error)
|
||||
|
||||
// Configuration methods
|
||||
SetHTTPClient(client *http.Client)
|
||||
GetConfig() config.BpjsConfig
|
||||
}
|
||||
|
||||
// Service struct for VClaim service
|
||||
type Service struct {
|
||||
config config.BpjsConfig
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Response structures
|
||||
type RawResponseDTO struct {
|
||||
MetaData struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"metaData"`
|
||||
Response string `json:"response"`
|
||||
}
|
||||
|
||||
type ResponseDTO struct {
|
||||
MetaData struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"metaData"`
|
||||
Response interface{} `json:"response"`
|
||||
}
|
||||
|
||||
// Request headers structure
|
||||
type RequestHeaders struct {
|
||||
ConsID string
|
||||
SecretKey string
|
||||
UserKey string
|
||||
Timestamp string
|
||||
Signature string
|
||||
AuthHeader string
|
||||
}
|
||||
|
||||
// Decryption result structure
|
||||
type DecryptionResult struct {
|
||||
Success bool
|
||||
Data string
|
||||
Error error
|
||||
Method string
|
||||
}
|
||||
|
||||
// Decompression result structure
|
||||
type DecompressionResult struct {
|
||||
Success bool
|
||||
Data string
|
||||
Error error
|
||||
Method string
|
||||
}
|
||||
|
||||
// Crypto configuration
|
||||
type CryptoConfig struct {
|
||||
KeyLength int
|
||||
IVLength int
|
||||
PaddingScheme string
|
||||
CipherMode string
|
||||
}
|
||||
|
||||
// Decompression configuration
|
||||
type DecompressionConfig struct {
|
||||
MaxRetries int
|
||||
Timeout time.Duration
|
||||
EnabledMethods []string
|
||||
FallbackMethods []string
|
||||
}
|
||||
|
||||
// HTTP configuration
|
||||
type HTTPConfig struct {
|
||||
Timeout time.Duration
|
||||
MaxRetries int
|
||||
RetryDelay time.Duration
|
||||
UserAgent string
|
||||
CompressionType string
|
||||
EnableKeepAlive bool
|
||||
MaxIdleConns int
|
||||
IdleConnTimeout time.Duration
|
||||
}
|
||||
|
||||
// Error types
|
||||
type VClaimError struct {
|
||||
Code string
|
||||
Message string
|
||||
Endpoint string
|
||||
HTTPStatus int
|
||||
Timestamp time.Time
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *VClaimError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return e.Message + ": " + e.Cause.Error()
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func (e *VClaimError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// Constants
|
||||
const (
|
||||
// Cipher modes
|
||||
CipherModeCBC = "CBC"
|
||||
CipherModeECB = "ECB"
|
||||
CipherModeGCM = "GCM"
|
||||
|
||||
// Padding schemes
|
||||
PaddingPKCS7 = "PKCS7"
|
||||
PaddingANSI = "ANSI"
|
||||
PaddingISO = "ISO"
|
||||
|
||||
// Decompression methods
|
||||
MethodLZString = "lzstring"
|
||||
MethodGzip = "gzip"
|
||||
MethodBase64 = "base64"
|
||||
MethodPlain = "plain"
|
||||
|
||||
// HTTP methods
|
||||
MethodGET = "GET"
|
||||
MethodPOST = "POST"
|
||||
MethodPUT = "PUT"
|
||||
MethodPATCH = "PATCH"
|
||||
MethodDELETE = "DELETE"
|
||||
|
||||
// Default configurations
|
||||
DefaultTimeout = 30 * time.Second
|
||||
DefaultMaxRetries = 3
|
||||
DefaultRetryDelay = 1 * time.Second
|
||||
DefaultKeyLength = 32
|
||||
DefaultIVLength = 16
|
||||
DefaultPaddingScheme = PaddingPKCS7
|
||||
DefaultCipherMode = CipherModeCBC
|
||||
DefaultUserAgent = "BPJS-VClaim-Service/1.0"
|
||||
DefaultCompressionType = "gzip"
|
||||
)
|
||||
|
||||
// Default configurations
|
||||
var (
|
||||
DefaultCryptoConfig = CryptoConfig{
|
||||
KeyLength: DefaultKeyLength,
|
||||
IVLength: DefaultIVLength,
|
||||
PaddingScheme: DefaultPaddingScheme,
|
||||
CipherMode: DefaultCipherMode,
|
||||
}
|
||||
|
||||
DefaultDecompressionConfig = DecompressionConfig{
|
||||
MaxRetries: 3,
|
||||
Timeout: 10 * time.Second,
|
||||
EnabledMethods: []string{MethodLZString, MethodGzip, MethodBase64, MethodPlain},
|
||||
FallbackMethods: []string{MethodPlain},
|
||||
}
|
||||
|
||||
DefaultHTTPConfig = HTTPConfig{
|
||||
Timeout: DefaultTimeout,
|
||||
MaxRetries: DefaultMaxRetries,
|
||||
RetryDelay: DefaultRetryDelay,
|
||||
UserAgent: DefaultUserAgent,
|
||||
CompressionType: DefaultCompressionType,
|
||||
}
|
||||
)
|
||||
|
||||
// ContextKey digunakan untuk mengirimkan token secara dinamis via context
|
||||
type ContextKey string
|
||||
|
||||
const TokenContextKey ContextKey = "x-token"
|
||||
Reference in New Issue
Block a user