feat: sync current progress (P0 hardening + P1 observability + deploy docs/systemd)
This commit is contained in:
20
internal/api/accesslog.go
Normal file
20
internal/api/accesslog.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"asset-tracker/internal/metrics"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AccessLog() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
latency := time.Since(start)
|
||||
metrics.ObserveHTTP(c, start)
|
||||
log.Printf("[http] request_id=%s method=%s path=%s status=%d latency=%s ip=%s", requestID(c), c.Request.Method, c.Request.URL.Path, c.Writer.Status(), latency.String(), c.ClientIP())
|
||||
}
|
||||
}
|
||||
26
internal/api/bizlog.go
Normal file
26
internal/api/bizlog.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func bizLog(c *gin.Context, level, module, action string, kv map[string]any) {
|
||||
log.Printf("[biz][%s] request_id=%s user_id=%d module=%s action=%s kv=%v", level, requestID(c), c.GetUint("user_id"), module, action, kv)
|
||||
}
|
||||
|
||||
func bizInfo(c *gin.Context, module, action string, kv map[string]any) {
|
||||
bizLog(c, "INFO", module, action, kv)
|
||||
}
|
||||
|
||||
func bizError(c *gin.Context, module, action, code string, err error, kv map[string]any) {
|
||||
if kv == nil {
|
||||
kv = map[string]any{}
|
||||
}
|
||||
kv["code"] = code
|
||||
if err != nil {
|
||||
kv["error"] = err.Error()
|
||||
}
|
||||
bizLog(c, "ERROR", module, action, kv)
|
||||
}
|
||||
778
internal/api/handlers.go
Normal file
778
internal/api/handlers.go
Normal file
@@ -0,0 +1,778 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"asset-tracker/internal/auth"
|
||||
"asset-tracker/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
db *gorm.DB
|
||||
tm *auth.TokenManager
|
||||
}
|
||||
|
||||
func NewHandler(db *gorm.DB, tm *auth.TokenManager) *Handler {
|
||||
return &Handler{db: db, tm: tm}
|
||||
}
|
||||
|
||||
func toJSON(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (h *Handler) writeAudit(userID uint, entityType string, entityID uint, action string, before any, after any) {
|
||||
log := model.AuditLog{
|
||||
UserID: userID,
|
||||
EntityType: entityType,
|
||||
EntityID: entityID,
|
||||
Action: action,
|
||||
BeforeJSON: toJSON(before),
|
||||
AfterJSON: toJSON(after),
|
||||
}
|
||||
_ = h.db.Create(&log).Error
|
||||
}
|
||||
|
||||
var currencyPattern = regexp.MustCompile(`^[A-Z]{3,10}$`)
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
var req loginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
bizError(c, "auth", "login", "BAD_REQUEST", err, nil)
|
||||
JSONBadRequest(c, "BAD_REQUEST", "invalid request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := h.db.Where("username = ?", strings.TrimSpace(req.Username)).First(&user).Error; err != nil {
|
||||
bizError(c, "auth", "login", "AUTH_INVALID_CREDENTIALS", err, map[string]any{"username": strings.TrimSpace(req.Username)})
|
||||
JSONUnauthorized(c, "AUTH_INVALID_CREDENTIALS", "invalid username or password")
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
bizError(c, "auth", "login", "AUTH_INVALID_CREDENTIALS", err, map[string]any{"username": user.Username, "uid": user.ID})
|
||||
JSONUnauthorized(c, "AUTH_INVALID_CREDENTIALS", "invalid username or password")
|
||||
return
|
||||
}
|
||||
|
||||
access, err := h.tm.GenerateAccessToken(user.ID, user.Username, user.Timezone)
|
||||
if err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
refresh, jti, exp, err := h.tm.GenerateRefreshToken(user.ID, user.Username, user.Timezone)
|
||||
if err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.db.Create(&model.RefreshSession{UserID: user.ID, JTI: jti, ExpiresAt: exp}).Error; err != nil {
|
||||
bizError(c, "auth", "login", "REFRESH_SESSION_CREATE_FAILED", err, map[string]any{"uid": user.ID})
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
secure := strings.EqualFold(strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")), "https") || c.Request.TLS != nil
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie("refresh_token", refresh, h.tm.RefreshMaxAgeSeconds(), "/api/v1/auth/refresh", "", secure, true)
|
||||
|
||||
bizInfo(c, "auth", "login", map[string]any{"uid": user.ID, "username": user.Username})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"access_token": access,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
}
|
||||
|
||||
type refreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handler) Refresh(c *gin.Context) {
|
||||
refreshToken := strings.TrimSpace(c.GetHeader("X-Refresh-Token"))
|
||||
if refreshToken == "" {
|
||||
if cookie, err := c.Cookie("refresh_token"); err == nil {
|
||||
refreshToken = strings.TrimSpace(cookie)
|
||||
}
|
||||
}
|
||||
if refreshToken == "" {
|
||||
var req refreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err == nil {
|
||||
refreshToken = strings.TrimSpace(req.RefreshToken)
|
||||
}
|
||||
}
|
||||
if refreshToken == "" {
|
||||
bizError(c, "auth", "refresh", "AUTH_MISSING_REFRESH", nil, nil)
|
||||
JSONUnauthorized(c, "AUTH_MISSING_REFRESH", "missing refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := h.tm.ParseAndValidate(refreshToken, "refresh")
|
||||
if err != nil {
|
||||
JSONUnauthorized(c, "AUTH_INVALID_REFRESH", "invalid refresh token")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(claims.ID) == "" {
|
||||
JSONUnauthorized(c, "AUTH_INVALID_REFRESH", "invalid refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
var session model.RefreshSession
|
||||
if err := h.db.Where("jti = ? AND user_id = ?", claims.ID, claims.UserID).First(&session).Error; err != nil {
|
||||
bizError(c, "auth", "refresh", "AUTH_INVALID_REFRESH", err, map[string]any{"uid": claims.UserID, "jti": claims.ID})
|
||||
JSONUnauthorized(c, "AUTH_INVALID_REFRESH", "invalid refresh token")
|
||||
return
|
||||
}
|
||||
if session.RevokedAt != nil || session.ExpiresAt.Before(time.Now().UTC()) {
|
||||
bizError(c, "auth", "refresh", "AUTH_INVALID_REFRESH", nil, map[string]any{"uid": claims.UserID, "jti": claims.ID, "revoked": session.RevokedAt != nil})
|
||||
JSONUnauthorized(c, "AUTH_INVALID_REFRESH", "invalid refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
access, err := h.tm.GenerateAccessToken(claims.UserID, claims.Username, claims.Timezone)
|
||||
if err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
newRefresh, newJTI, newExp, err := h.tm.GenerateRefreshToken(claims.UserID, claims.Username, claims.Timezone)
|
||||
if err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
now := time.Now().UTC()
|
||||
if err := tx.Model(&model.RefreshSession{}).Where("id = ?", session.ID).Updates(map[string]any{"revoked_at": &now, "replaced_by": newJTI}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&model.RefreshSession{UserID: claims.UserID, JTI: newJTI, ExpiresAt: newExp}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
bizError(c, "auth", "refresh", "REFRESH_ROTATE_FAILED", err, map[string]any{"uid": claims.UserID, "old_jti": claims.ID, "new_jti": newJTI})
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
secure := strings.EqualFold(strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")), "https") || c.Request.TLS != nil
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie("refresh_token", newRefresh, h.tm.RefreshMaxAgeSeconds(), "/api/v1/auth/refresh", "", secure, true)
|
||||
|
||||
bizInfo(c, "auth", "refresh", map[string]any{"uid": claims.UserID, "old_jti": claims.ID, "new_jti": newJTI})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"access_token": access,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
}
|
||||
|
||||
type createCategoryRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Type string `json:"type" binding:"required,oneof=real digital"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateCategory(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
var req createCategoryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
bizError(c, "category", "create", "BAD_REQUEST", err, nil)
|
||||
JSONBadRequest(c, "BAD_REQUEST", "invalid request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
cat := model.Category{
|
||||
UserID: userID,
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Type: req.Type,
|
||||
Color: strings.TrimSpace(req.Color),
|
||||
}
|
||||
if cat.Name == "" {
|
||||
bizError(c, "category", "create", "CATEGORY_NAME_REQUIRED", nil, nil)
|
||||
JSONBadRequest(c, "CATEGORY_NAME_REQUIRED", "name is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Create(&cat).Error; err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
bizError(c, "category", "create", "CATEGORY_DUPLICATE", err, map[string]any{"name": cat.Name})
|
||||
JSONError(c, http.StatusConflict, "CATEGORY_DUPLICATE", "category already exists", nil)
|
||||
return
|
||||
}
|
||||
bizError(c, "category", "create", "INTERNAL_ERROR", err, nil)
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
bizInfo(c, "category", "create", map[string]any{"category_id": cat.ID, "name": cat.Name})
|
||||
c.JSON(http.StatusCreated, gin.H{"data": cat})
|
||||
}
|
||||
|
||||
func (h *Handler) ListCategories(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
var categories []model.Category
|
||||
if err := h.db.Where("user_id = ?", userID).Order("id desc").Find(&categories).Error; err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": categories})
|
||||
}
|
||||
|
||||
type createAssetRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
CategoryID uint `json:"category_id" binding:"required"`
|
||||
Quantity float64 `json:"quantity" binding:"required"`
|
||||
UnitPrice float64 `json:"unit_price" binding:"required"`
|
||||
Currency string `json:"currency" binding:"required"`
|
||||
ExpiryDate string `json:"expiry_date"`
|
||||
Note string `json:"note"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type updateAssetRequest struct {
|
||||
Name *string `json:"name"`
|
||||
CategoryID *uint `json:"category_id"`
|
||||
Quantity *float64 `json:"quantity"`
|
||||
UnitPrice *float64 `json:"unit_price"`
|
||||
Currency *string `json:"currency"`
|
||||
ExpiryDate *string `json:"expiry_date"`
|
||||
Note *string `json:"note"`
|
||||
Status *string `json:"status"`
|
||||
}
|
||||
|
||||
func parseExpiryToUTC(raw string) (*time.Time, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u := parsed.UTC()
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (h *Handler) CreateAsset(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
var req createAssetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
bizError(c, "asset", "create", "BAD_REQUEST", err, nil)
|
||||
JSONBadRequest(c, "BAD_REQUEST", "invalid request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Quantity < 0 || req.UnitPrice < 0 {
|
||||
JSONBadRequest(c, "ASSET_NEGATIVE_VALUE", "quantity and unit_price must be >= 0", nil)
|
||||
return
|
||||
}
|
||||
|
||||
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
||||
if !currencyPattern.MatchString(currency) {
|
||||
JSONBadRequest(c, "ASSET_INVALID_CURRENCY", "currency must match [A-Z]{3,10}", nil)
|
||||
return
|
||||
}
|
||||
|
||||
status := strings.TrimSpace(req.Status)
|
||||
if status == "" {
|
||||
status = "active"
|
||||
}
|
||||
if status != "active" && status != "inactive" {
|
||||
JSONBadRequest(c, "ASSET_INVALID_STATUS", "status must be active or inactive", nil)
|
||||
return
|
||||
}
|
||||
|
||||
expiry, err := parseExpiryToUTC(req.ExpiryDate)
|
||||
if err != nil {
|
||||
JSONBadRequest(c, "ASSET_INVALID_EXPIRY", "expiry_date must be RFC3339", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := h.db.Model(&model.Category{}).Where("id = ? AND user_id = ?", req.CategoryID, userID).Count(&count).Error; err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
if count == 0 {
|
||||
JSONBadRequest(c, "CATEGORY_NOT_FOUND", "category not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
asset := model.Asset{
|
||||
UserID: userID,
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
CategoryID: req.CategoryID,
|
||||
Quantity: req.Quantity,
|
||||
UnitPrice: req.UnitPrice,
|
||||
TotalValue: req.Quantity * req.UnitPrice,
|
||||
Currency: currency,
|
||||
ExpiryDate: expiry,
|
||||
Note: strings.TrimSpace(req.Note),
|
||||
Status: status,
|
||||
}
|
||||
if asset.Name == "" {
|
||||
JSONBadRequest(c, "CATEGORY_NAME_REQUIRED", "name is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Create(&asset).Error; err != nil {
|
||||
bizError(c, "asset", "create", "INTERNAL_ERROR", err, nil)
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.ensureRemindersForAsset(asset)
|
||||
h.writeAudit(userID, "asset", asset.ID, "create", nil, asset)
|
||||
bizInfo(c, "asset", "create", map[string]any{"asset_id": asset.ID, "name": asset.Name})
|
||||
c.JSON(http.StatusCreated, gin.H{"data": formatAssetForTZ(asset, c.GetString("timezone"))})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateAsset(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
assetID := c.Param("id")
|
||||
|
||||
var asset model.Asset
|
||||
if err := h.db.Where("id = ? AND user_id = ?", assetID, userID).First(&asset).Error; err != nil {
|
||||
bizError(c, "asset", "update", "ASSET_NOT_FOUND", err, map[string]any{"asset_id": assetID})
|
||||
JSONError(c, http.StatusNotFound, "ASSET_NOT_FOUND", "asset not found", nil)
|
||||
return
|
||||
}
|
||||
before := asset
|
||||
|
||||
var req updateAssetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
bizError(c, "asset", "update", "BAD_REQUEST", err, map[string]any{"asset_id": asset.ID})
|
||||
JSONBadRequest(c, "BAD_REQUEST", "invalid request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
asset.Name = strings.TrimSpace(*req.Name)
|
||||
if asset.Name == "" {
|
||||
JSONBadRequest(c, "ASSET_NAME_EMPTY", "name cannot be empty", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.CategoryID != nil {
|
||||
var count int64
|
||||
if err := h.db.Model(&model.Category{}).Where("id = ? AND user_id = ?", *req.CategoryID, userID).Count(&count).Error; err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
if count == 0 {
|
||||
JSONBadRequest(c, "CATEGORY_NOT_FOUND", "category not found", nil)
|
||||
return
|
||||
}
|
||||
asset.CategoryID = *req.CategoryID
|
||||
}
|
||||
if req.Quantity != nil {
|
||||
if *req.Quantity < 0 {
|
||||
JSONBadRequest(c, "ASSET_QUANTITY_NEGATIVE", "quantity must be >= 0", nil)
|
||||
return
|
||||
}
|
||||
asset.Quantity = *req.Quantity
|
||||
}
|
||||
if req.UnitPrice != nil {
|
||||
if *req.UnitPrice < 0 {
|
||||
JSONBadRequest(c, "ASSET_UNIT_PRICE_NEGATIVE", "unit_price must be >= 0", nil)
|
||||
return
|
||||
}
|
||||
asset.UnitPrice = *req.UnitPrice
|
||||
}
|
||||
if req.Currency != nil {
|
||||
cur := strings.ToUpper(strings.TrimSpace(*req.Currency))
|
||||
if !currencyPattern.MatchString(cur) {
|
||||
JSONBadRequest(c, "ASSET_INVALID_CURRENCY", "currency must match [A-Z]{3,10}", nil)
|
||||
return
|
||||
}
|
||||
asset.Currency = cur
|
||||
}
|
||||
if req.Status != nil {
|
||||
status := strings.TrimSpace(*req.Status)
|
||||
if status != "active" && status != "inactive" {
|
||||
JSONBadRequest(c, "ASSET_INVALID_STATUS", "status must be active or inactive", nil)
|
||||
return
|
||||
}
|
||||
asset.Status = status
|
||||
}
|
||||
if req.Note != nil {
|
||||
asset.Note = strings.TrimSpace(*req.Note)
|
||||
}
|
||||
if req.ExpiryDate != nil {
|
||||
expiry, err := parseExpiryToUTC(*req.ExpiryDate)
|
||||
if err != nil {
|
||||
JSONBadRequest(c, "ASSET_INVALID_EXPIRY", "expiry_date must be RFC3339", nil)
|
||||
return
|
||||
}
|
||||
asset.ExpiryDate = expiry
|
||||
}
|
||||
|
||||
asset.TotalValue = asset.Quantity * asset.UnitPrice
|
||||
if err := h.db.Save(&asset).Error; err != nil {
|
||||
bizError(c, "asset", "update", "INTERNAL_ERROR", err, map[string]any{"asset_id": asset.ID})
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.ensureRemindersForAsset(asset)
|
||||
h.writeAudit(userID, "asset", asset.ID, "update", before, asset)
|
||||
bizInfo(c, "asset", "update", map[string]any{"asset_id": asset.ID, "status": asset.Status})
|
||||
c.JSON(http.StatusOK, gin.H{"data": formatAssetForTZ(asset, c.GetString("timezone"))})
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteAsset(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
assetID := c.Param("id")
|
||||
|
||||
var asset model.Asset
|
||||
if err := h.db.Where("id = ? AND user_id = ?", assetID, userID).First(&asset).Error; err != nil {
|
||||
bizError(c, "asset", "delete", "ASSET_NOT_FOUND", err, map[string]any{"asset_id": assetID})
|
||||
JSONError(c, http.StatusNotFound, "ASSET_NOT_FOUND", "asset not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("asset_id = ? AND user_id = ?", asset.ID, userID).Delete(&model.Reminder{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Delete(&asset).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
log := model.AuditLog{
|
||||
UserID: userID,
|
||||
EntityType: "asset",
|
||||
EntityID: asset.ID,
|
||||
Action: "delete",
|
||||
BeforeJSON: toJSON(asset),
|
||||
AfterJSON: "null",
|
||||
}
|
||||
return tx.Create(&log).Error
|
||||
}); err != nil {
|
||||
bizError(c, "asset", "delete", "INTERNAL_ERROR", err, map[string]any{"asset_id": asset.ID})
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
bizInfo(c, "asset", "delete", map[string]any{"asset_id": asset.ID})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted", "request_id": requestID(c)})
|
||||
}
|
||||
|
||||
func (h *Handler) ListAssets(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
var assets []model.Asset
|
||||
query := h.db.Model(&model.Asset{}).Where("user_id = ?", userID).Order("id desc")
|
||||
|
||||
categoryID := strings.TrimSpace(c.Query("category_id"))
|
||||
if categoryID != "" {
|
||||
query = query.Where("category_id = ?", categoryID)
|
||||
}
|
||||
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
if status != "" {
|
||||
if status != "active" && status != "inactive" {
|
||||
JSONBadRequest(c, "ASSET_INVALID_STATUS", "status must be active or inactive", nil)
|
||||
return
|
||||
}
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
page := 1
|
||||
pageSize := 20
|
||||
if p := strings.TrimSpace(c.Query("page")); p != "" {
|
||||
fmt.Sscanf(p, "%d", &page)
|
||||
}
|
||||
if ps := strings.TrimSpace(c.Query("page_size")); ps != "" {
|
||||
fmt.Sscanf(ps, "%d", &pageSize)
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
if err := query.Offset(offset).Limit(pageSize).Find(&assets).Error; err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]gin.H, 0, len(assets))
|
||||
for _, a := range assets {
|
||||
resp = append(resp, formatAssetForTZ(a, c.GetString("timezone")))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": resp, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
func (h *Handler) PublicRecords(c *gin.Context) {
|
||||
tz := strings.TrimSpace(c.Query("timezone"))
|
||||
if tz == "" {
|
||||
tz = "Asia/Shanghai"
|
||||
}
|
||||
|
||||
var assets []model.Asset
|
||||
if err := h.db.Where("user_id = ?", 1).Order("updated_at desc").Limit(100).Find(&assets).Error; err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var categories []model.Category
|
||||
_ = h.db.Where("user_id = ?", 1).Find(&categories).Error
|
||||
catName := map[uint]string{}
|
||||
for _, x := range categories {
|
||||
catName[x.ID] = x.Name
|
||||
}
|
||||
|
||||
total := 0.0
|
||||
activeCount := 0
|
||||
byCat := map[string]float64{}
|
||||
resp := make([]gin.H, 0, len(assets))
|
||||
for _, a := range assets {
|
||||
if a.Status == "active" {
|
||||
total += a.TotalValue
|
||||
activeCount++
|
||||
byCat[catName[a.CategoryID]] += a.TotalValue
|
||||
}
|
||||
row := formatAssetForTZ(a, tz)
|
||||
row["category_name"] = catName[a.CategoryID]
|
||||
resp = append(resp, row)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"summary": gin.H{
|
||||
"user_id": 1,
|
||||
"active_asset_count": activeCount,
|
||||
"total_assets_value": total,
|
||||
"by_category": byCat,
|
||||
},
|
||||
"records": resp,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ListReminders(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
query := h.db.Model(&model.Reminder{}).Where("user_id = ?", userID).Order("status asc, remind_at asc, id desc")
|
||||
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
if status != "" {
|
||||
allowed := map[string]bool{"pending": true, "sending": true, "sent": true, "failed": true}
|
||||
if !allowed[status] {
|
||||
JSONBadRequest(c, "REMINDER_INVALID_STATUS", "invalid status", nil)
|
||||
return
|
||||
}
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
page := 1
|
||||
pageSize := 20
|
||||
if p := strings.TrimSpace(c.Query("page")); p != "" {
|
||||
fmt.Sscanf(p, "%d", &page)
|
||||
}
|
||||
if ps := strings.TrimSpace(c.Query("page_size")); ps != "" {
|
||||
fmt.Sscanf(ps, "%d", &pageSize)
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var rows []model.Reminder
|
||||
if err := query.Offset(offset).Limit(pageSize).Find(&rows).Error; err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
assetIDs := make([]uint, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
assetIDs = append(assetIDs, r.AssetID)
|
||||
}
|
||||
nameMap := map[uint]string{}
|
||||
if len(assetIDs) > 0 {
|
||||
var assets []model.Asset
|
||||
_ = h.db.Where("id IN ? AND user_id = ?", assetIDs, userID).Find(&assets).Error
|
||||
for _, a := range assets {
|
||||
nameMap[a.ID] = a.Name
|
||||
}
|
||||
}
|
||||
|
||||
loc, err := time.LoadLocation(c.GetString("timezone"))
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
resp := make([]gin.H, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
item := gin.H{
|
||||
"id": r.ID,
|
||||
"asset_id": r.AssetID,
|
||||
"asset_name": nameMap[r.AssetID],
|
||||
"remind_at": r.RemindAt.In(loc).Format(time.RFC3339),
|
||||
"channel": r.Channel,
|
||||
"status": r.Status,
|
||||
"retry_count": r.RetryCount,
|
||||
"last_error": r.LastError,
|
||||
"created_at": r.CreatedAt.In(loc).Format(time.RFC3339),
|
||||
"updated_at": r.UpdatedAt.In(loc).Format(time.RFC3339),
|
||||
}
|
||||
if r.SentAt != nil {
|
||||
item["sent_at"] = r.SentAt.In(loc).Format(time.RFC3339)
|
||||
}
|
||||
if r.NextRetryAt != nil {
|
||||
item["next_retry_at"] = r.NextRetryAt.In(loc).Format(time.RFC3339)
|
||||
}
|
||||
resp = append(resp, item)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": resp, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
func (h *Handler) DashboardSummary(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
var assets []model.Asset
|
||||
if err := h.db.Where("user_id = ? AND status = ?", userID, "active").Find(&assets).Error; err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
total := 0.0
|
||||
for _, a := range assets {
|
||||
total += a.TotalValue
|
||||
}
|
||||
|
||||
type categoryAgg struct {
|
||||
CategoryID uint `json:"category_id"`
|
||||
CategoryName string `json:"category_name"`
|
||||
TotalValue float64 `json:"total_value"`
|
||||
Ratio float64 `json:"ratio"`
|
||||
}
|
||||
|
||||
nameMap := map[uint]string{}
|
||||
var categories []model.Category
|
||||
_ = h.db.Where("user_id = ?", userID).Find(&categories).Error
|
||||
for _, cat := range categories {
|
||||
nameMap[cat.ID] = cat.Name
|
||||
}
|
||||
|
||||
byCatMap := map[uint]float64{}
|
||||
for _, a := range assets {
|
||||
byCatMap[a.CategoryID] += a.TotalValue
|
||||
}
|
||||
|
||||
byCategory := make([]categoryAgg, 0, len(byCatMap))
|
||||
for categoryID, v := range byCatMap {
|
||||
ratio := 0.0
|
||||
if total > 0 {
|
||||
ratio = v / total
|
||||
}
|
||||
byCategory = append(byCategory, categoryAgg{
|
||||
CategoryID: categoryID,
|
||||
CategoryName: nameMap[categoryID],
|
||||
TotalValue: v,
|
||||
Ratio: ratio,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(byCategory, func(i, j int) bool { return byCategory[i].TotalValue > byCategory[j].TotalValue })
|
||||
|
||||
nowUTC := time.Now().UTC()
|
||||
endUTC := nowUTC.Add(30 * 24 * time.Hour)
|
||||
var expiring []model.Asset
|
||||
if err := h.db.Where("user_id = ? AND status = ? AND expiry_date IS NOT NULL AND expiry_date >= ? AND expiry_date <= ?", userID, "active", nowUTC, endUTC).Order("expiry_date asc").Find(&expiring).Error; err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
expiringResp := make([]gin.H, 0, len(expiring))
|
||||
for _, a := range expiring {
|
||||
expiringResp = append(expiringResp, formatAssetForTZ(a, c.GetString("timezone")))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"total_assets_value": total,
|
||||
"by_category": byCategory,
|
||||
"expiring_in_30_days": expiringResp,
|
||||
})
|
||||
}
|
||||
|
||||
func formatAssetForTZ(a model.Asset, tz string) gin.H {
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
var expiry any
|
||||
if a.ExpiryDate != nil {
|
||||
expiry = a.ExpiryDate.In(loc).Format(time.RFC3339)
|
||||
}
|
||||
return gin.H{
|
||||
"id": a.ID,
|
||||
"name": a.Name,
|
||||
"category_id": a.CategoryID,
|
||||
"quantity": a.Quantity,
|
||||
"unit_price": a.UnitPrice,
|
||||
"total_value": a.TotalValue,
|
||||
"currency": a.Currency,
|
||||
"expiry_date": expiry,
|
||||
"note": a.Note,
|
||||
"status": a.Status,
|
||||
"created_at": a.CreatedAt.In(loc).Format(time.RFC3339),
|
||||
"updated_at": a.UpdatedAt.In(loc).Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ensureRemindersForAsset(asset model.Asset) {
|
||||
_ = h.db.Where("asset_id = ? AND user_id = ? AND status IN ?", asset.ID, asset.UserID, []string{"pending", "failed", "sending"}).Delete(&model.Reminder{}).Error
|
||||
if asset.ExpiryDate == nil || asset.Status != "active" {
|
||||
return
|
||||
}
|
||||
base := asset.ExpiryDate.UTC()
|
||||
days := []int{30, 7, 1}
|
||||
for _, d := range days {
|
||||
remindAt := base.Add(-time.Duration(d) * 24 * time.Hour)
|
||||
dedupe := fmt.Sprintf("asset:%d:at:%s:ch:in_app", asset.ID, remindAt.Format(time.RFC3339))
|
||||
r := model.Reminder{
|
||||
UserID: asset.UserID,
|
||||
AssetID: asset.ID,
|
||||
RemindAt: remindAt,
|
||||
Channel: "in_app",
|
||||
Status: "pending",
|
||||
DedupeKey: dedupe,
|
||||
}
|
||||
_ = h.db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "dedupe_key"}}, DoNothing: true}).Create(&r).Error
|
||||
}
|
||||
}
|
||||
31
internal/api/middleware.go
Normal file
31
internal/api/middleware.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"asset-tracker/internal/auth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AuthRequired(tm *auth.TokenManager) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
JSONUnauthorized(c, "AUTH_MISSING_BEARER", "missing bearer token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
|
||||
claims, err := tm.ParseAndValidate(token, "access")
|
||||
if err != nil {
|
||||
JSONUnauthorized(c, "AUTH_INVALID_TOKEN", "invalid token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("timezone", claims.Timezone)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
25
internal/api/requestid.go
Normal file
25
internal/api/requestid.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RequestID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.GetHeader("X-Request-Id")
|
||||
if id == "" {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err == nil {
|
||||
id = hex.EncodeToString(b)
|
||||
} else {
|
||||
id = "req-unknown"
|
||||
}
|
||||
}
|
||||
c.Set("request_id", id)
|
||||
c.Header("X-Request-Id", id)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
44
internal/api/response.go
Normal file
44
internal/api/response.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ErrorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details any `json:"details,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
}
|
||||
|
||||
func requestID(c *gin.Context) string {
|
||||
if v, ok := c.Get("request_id"); ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func JSONError(c *gin.Context, status int, code, message string, details any) {
|
||||
c.JSON(status, ErrorBody{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Details: details,
|
||||
RequestID: requestID(c),
|
||||
})
|
||||
}
|
||||
|
||||
func JSONBadRequest(c *gin.Context, code, message string, details any) {
|
||||
JSONError(c, http.StatusBadRequest, code, message, details)
|
||||
}
|
||||
|
||||
func JSONUnauthorized(c *gin.Context, code, message string) {
|
||||
JSONError(c, http.StatusUnauthorized, code, message, nil)
|
||||
}
|
||||
|
||||
func JSONInternal(c *gin.Context, message string, details any) {
|
||||
JSONError(c, http.StatusInternalServerError, "INTERNAL_ERROR", message, details)
|
||||
}
|
||||
87
internal/api/router.go
Normal file
87
internal/api/router.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"asset-tracker/internal/auth"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
func RegisterRoutes(r *gin.Engine, h *Handler, tm *auth.TokenManager) {
|
||||
r.Static("/legacy/static", "./web/legacy/static")
|
||||
r.GET("/legacy", func(c *gin.Context) {
|
||||
c.File("./web/legacy/index.html")
|
||||
})
|
||||
r.GET("/legacy/records", func(c *gin.Context) {
|
||||
c.File("./web/legacy/records.html")
|
||||
})
|
||||
|
||||
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
|
||||
r.Static("/_assets", "./web/dist/_assets")
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
c.File("./web/dist/index.html")
|
||||
})
|
||||
r.GET("/public/records", h.PublicRecords)
|
||||
// status endpoint moved here for diagnostics
|
||||
r.GET("/status", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"name": "asset-tracker",
|
||||
"status": "running",
|
||||
"health": "/health",
|
||||
"api_base": "/api/v1",
|
||||
"web_app": "/app",
|
||||
})
|
||||
})
|
||||
|
||||
r.GET("/app", func(c *gin.Context) {
|
||||
c.File("./web/dist/index.html")
|
||||
})
|
||||
r.GET("/app/", func(c *gin.Context) {
|
||||
c.File("./web/dist/index.html")
|
||||
})
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
if c.Request.Method == http.MethodGet {
|
||||
path := c.Request.URL.Path
|
||||
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/public/") || strings.HasPrefix(path, "/health") || strings.HasPrefix(path, "/status") || strings.HasPrefix(path, "/legacy") {
|
||||
JSONError(c, http.StatusNotFound, "NOT_FOUND", "not found", nil)
|
||||
return
|
||||
}
|
||||
c.File("./web/dist/index.html")
|
||||
return
|
||||
}
|
||||
JSONError(c, http.StatusNotFound, "NOT_FOUND", "not found", nil)
|
||||
})
|
||||
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
r.GET("/healthz", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
r.GET("/readyz", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ready"})
|
||||
})
|
||||
|
||||
v1 := r.Group("/api/v1")
|
||||
{
|
||||
v1.POST("/auth/login", h.Login)
|
||||
v1.POST("/auth/refresh", h.Refresh)
|
||||
|
||||
secured := v1.Group("")
|
||||
secured.Use(AuthRequired(tm))
|
||||
{
|
||||
secured.POST("/categories", h.CreateCategory)
|
||||
secured.GET("/categories", h.ListCategories)
|
||||
|
||||
secured.POST("/assets", h.CreateAsset)
|
||||
secured.GET("/assets", h.ListAssets)
|
||||
secured.PUT("/assets/:id", h.UpdateAsset)
|
||||
secured.DELETE("/assets/:id", h.DeleteAsset)
|
||||
|
||||
secured.GET("/dashboard/summary", h.DashboardSummary)
|
||||
secured.GET("/reminders", h.ListReminders)
|
||||
}
|
||||
}
|
||||
}
|
||||
108
internal/auth/jwt.go
Normal file
108
internal/auth/jwt.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Timezone string `json:"timezone"`
|
||||
Type string `json:"type"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type TokenManager struct {
|
||||
secret []byte
|
||||
accessTTLMinutes int
|
||||
refreshTTLHours int
|
||||
}
|
||||
|
||||
func (tm *TokenManager) RefreshMaxAgeSeconds() int {
|
||||
return tm.refreshTTLHours * 3600
|
||||
}
|
||||
|
||||
func NewTokenManager(secret string, accessTTLMinutes, refreshTTLHours int) *TokenManager {
|
||||
return &TokenManager{
|
||||
secret: []byte(secret),
|
||||
accessTTLMinutes: accessTTLMinutes,
|
||||
refreshTTLHours: refreshTTLHours,
|
||||
}
|
||||
}
|
||||
|
||||
func (tm *TokenManager) GenerateAccessToken(userID uint, username, timezone string) (string, error) {
|
||||
now := time.Now().UTC()
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Timezone: timezone,
|
||||
Type: "access",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(tm.accessTTLMinutes) * time.Minute)),
|
||||
Subject: username,
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(tm.secret)
|
||||
}
|
||||
|
||||
func (tm *TokenManager) GenerateRefreshToken(userID uint, username, timezone string) (string, string, time.Time, error) {
|
||||
now := time.Now().UTC()
|
||||
expiresAt := now.Add(time.Duration(tm.refreshTTLHours) * time.Hour)
|
||||
jti, err := randomJTI()
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Timezone: timezone,
|
||||
Type: "refresh",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
Subject: username,
|
||||
ID: jti,
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenStr, err := token.SignedString(tm.secret)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
return tokenStr, jti, expiresAt, nil
|
||||
}
|
||||
|
||||
func (tm *TokenManager) ParseAndValidate(tokenStr string, expectedType string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return tm.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
if claims.Type != expectedType {
|
||||
return nil, errors.New("invalid token type")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func randomJTI() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
70
internal/config/config.go
Normal file
70
internal/config/config.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTPAddr string
|
||||
DBPath string
|
||||
JWTSecret string
|
||||
AccessTTLMinutes int
|
||||
RefreshTTLHours int
|
||||
DefaultUsername string
|
||||
DefaultPassword string
|
||||
DefaultTimezone string
|
||||
AppEnv string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
addr := getenv("HTTP_ADDR", ":9530")
|
||||
dbPath := getenv("DB_PATH", "./data/asset-tracker.db")
|
||||
jwtSecret := getenv("JWT_SECRET", "change_me_in_production")
|
||||
defaultUsername := getenv("DEFAULT_USERNAME", "admin")
|
||||
defaultPassword := getenv("DEFAULT_PASSWORD", "admin123")
|
||||
defaultTimezone := getenv("DEFAULT_TIMEZONE", "Asia/Shanghai")
|
||||
appEnv := strings.ToLower(strings.TrimSpace(getenv("APP_ENV", "dev")))
|
||||
|
||||
accessTTL := getenvInt("ACCESS_TTL_MINUTES", 30)
|
||||
if accessTTL < 5 {
|
||||
accessTTL = 5
|
||||
}
|
||||
refreshTTL := getenvInt("REFRESH_TTL_HOURS", 168)
|
||||
if refreshTTL < 1 {
|
||||
refreshTTL = 1
|
||||
}
|
||||
|
||||
return Config{
|
||||
HTTPAddr: addr,
|
||||
DBPath: dbPath,
|
||||
JWTSecret: jwtSecret,
|
||||
AccessTTLMinutes: accessTTL,
|
||||
RefreshTTLHours: refreshTTL,
|
||||
DefaultUsername: defaultUsername,
|
||||
DefaultPassword: defaultPassword,
|
||||
DefaultTimezone: defaultTimezone,
|
||||
AppEnv: appEnv,
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func getenvInt(key string, fallback int) int {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
63
internal/metrics/metrics.go
Normal file
63
internal/metrics/metrics.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var (
|
||||
HTTPRequestsTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{Name: "http_requests_total", Help: "Total HTTP requests."},
|
||||
[]string{"method", "path", "status"},
|
||||
)
|
||||
HTTPRequestDuration = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{Name: "http_request_duration_seconds", Help: "HTTP request latency.", Buckets: prometheus.DefBuckets},
|
||||
[]string{"method", "path", "status"},
|
||||
)
|
||||
|
||||
ReminderSendTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{Name: "reminder_send_total", Help: "Reminder delivery results."},
|
||||
[]string{"status"},
|
||||
)
|
||||
ReminderRetryTotal = promauto.NewCounter(
|
||||
prometheus.CounterOpts{Name: "reminder_retry_total", Help: "Reminder retry count."},
|
||||
)
|
||||
|
||||
DBQueryDuration = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{Name: "db_query_duration_seconds", Help: "DB query duration.", Buckets: prometheus.DefBuckets},
|
||||
[]string{"op", "table", "success"},
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
ReminderSendTotal.WithLabelValues("sent").Add(0)
|
||||
ReminderSendTotal.WithLabelValues("failed").Add(0)
|
||||
ReminderRetryTotal.Add(0)
|
||||
DBQueryDuration.WithLabelValues("scan_pending", "reminders", "true").Observe(0)
|
||||
}
|
||||
|
||||
func ObserveHTTP(c *gin.Context, start time.Time) {
|
||||
path := c.FullPath()
|
||||
if path == "" {
|
||||
path = c.Request.URL.Path
|
||||
}
|
||||
status := strconv.Itoa(c.Writer.Status())
|
||||
labels := []string{c.Request.Method, path, status}
|
||||
HTTPRequestsTotal.WithLabelValues(labels...).Inc()
|
||||
HTTPRequestDuration.WithLabelValues(labels...).Observe(time.Since(start).Seconds())
|
||||
}
|
||||
|
||||
func ObserveDB(op, table string, success bool, dur time.Duration) {
|
||||
if table == "" {
|
||||
table = "unknown"
|
||||
}
|
||||
s := "false"
|
||||
if success {
|
||||
s = "true"
|
||||
}
|
||||
DBQueryDuration.WithLabelValues(op, table, s).Observe(dur.Seconds())
|
||||
}
|
||||
20
internal/model/asset.go
Normal file
20
internal/model/asset.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Asset struct {
|
||||
ID uint `json:"id" gorm:"primaryKey;index:idx_assets_user_status_id,priority:3"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index:idx_assets_user_status_category,priority:1;index:idx_assets_user_status_id,priority:1"`
|
||||
Name string `json:"name" gorm:"size:128;not null"`
|
||||
CategoryID uint `json:"category_id" gorm:"not null;index:idx_assets_user_status_category,priority:3"`
|
||||
Category Category `json:"-"`
|
||||
Quantity float64 `json:"quantity" gorm:"not null"`
|
||||
UnitPrice float64 `json:"unit_price" gorm:"not null"`
|
||||
TotalValue float64 `json:"total_value" gorm:"not null;index"`
|
||||
Currency string `json:"currency" gorm:"size:16;not null"`
|
||||
ExpiryDate *time.Time `json:"expiry_date,omitempty" gorm:"index"`
|
||||
Note string `json:"note" gorm:"type:text"`
|
||||
Status string `json:"status" gorm:"size:16;not null;default:active;check:status IN ('active','inactive');index:idx_assets_user_status_category,priority:2;index:idx_assets_user_status_id,priority:2"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
14
internal/model/audit_log.go
Normal file
14
internal/model/audit_log.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type AuditLog struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
EntityType string `json:"entity_type" gorm:"size:32;not null;index"`
|
||||
EntityID uint `json:"entity_id" gorm:"not null;index"`
|
||||
Action string `json:"action" gorm:"size:16;not null;index"`
|
||||
BeforeJSON string `json:"before_json" gorm:"type:text"`
|
||||
AfterJSON string `json:"after_json" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
13
internal/model/category.go
Normal file
13
internal/model/category.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Category struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UserID uint `json:"user_id" gorm:"not null;uniqueIndex:uidx_categories_user_name,priority:1;index"`
|
||||
Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uidx_categories_user_name,priority:2"`
|
||||
Type string `json:"type" gorm:"size:16;not null"`
|
||||
Color string `json:"color" gorm:"size:32"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
14
internal/model/refresh_session.go
Normal file
14
internal/model/refresh_session.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type RefreshSession struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
JTI string `json:"jti" gorm:"size:64;not null;uniqueIndex"`
|
||||
ExpiresAt time.Time `json:"expires_at" gorm:"not null;index"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty" gorm:"index"`
|
||||
ReplacedBy string `json:"replaced_by" gorm:"size:64"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
19
internal/model/reminder.go
Normal file
19
internal/model/reminder.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Reminder struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index;uniqueIndex:uq_reminder_identity,priority:1"`
|
||||
AssetID uint `json:"asset_id" gorm:"not null;index;uniqueIndex:uq_reminder_identity,priority:2"`
|
||||
RemindAt time.Time `json:"remind_at" gorm:"not null;index:idx_reminders_status_remind_at,priority:2;uniqueIndex:uq_reminder_identity,priority:3"`
|
||||
Channel string `json:"channel" gorm:"size:32;not null;default:in_app;uniqueIndex:uq_reminder_identity,priority:4"`
|
||||
Status string `json:"status" gorm:"size:16;not null;default:pending;check:status IN ('pending','sending','sent','failed');index:idx_reminders_status_remind_at,priority:1;index:idx_reminders_next_retry_status,priority:2;index:idx_reminders_status_next_retry,priority:1"`
|
||||
DedupeKey string `json:"dedupe_key" gorm:"size:128;not null;uniqueIndex"`
|
||||
RetryCount int `json:"retry_count" gorm:"not null;default:0"`
|
||||
NextRetryAt *time.Time `json:"next_retry_at,omitempty" gorm:"index:idx_reminders_next_retry_status,priority:1;index:idx_reminders_status_next_retry,priority:2"`
|
||||
LastError string `json:"last_error" gorm:"size:500"`
|
||||
SentAt *time.Time `json:"sent_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
17
internal/model/reminder_dead_letter.go
Normal file
17
internal/model/reminder_dead_letter.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type ReminderDeadLetter struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
ReminderID uint `json:"reminder_id" gorm:"not null;uniqueIndex"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
AssetID uint `json:"asset_id" gorm:"not null;index"`
|
||||
RemindAt time.Time `json:"remind_at" gorm:"not null;index"`
|
||||
Channel string `json:"channel" gorm:"size:32;not null"`
|
||||
Status string `json:"status" gorm:"size:16;not null"`
|
||||
RetryCount int `json:"retry_count" gorm:"not null"`
|
||||
LastError string `json:"last_error" gorm:"size:500"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
12
internal/model/user.go
Normal file
12
internal/model/user.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Username string `json:"username" gorm:"size:64;uniqueIndex;not null"`
|
||||
PasswordHash string `json:"-" gorm:"size:255;not null"`
|
||||
Timezone string `json:"timezone" gorm:"size:64;not null;default:UTC"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
171
internal/scheduler/reminder.go
Normal file
171
internal/scheduler/reminder.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"asset-tracker/internal/metrics"
|
||||
"asset-tracker/internal/model"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func StartReminderScan(db *gorm.DB) *cron.Cron {
|
||||
c := cron.New(cron.WithSeconds())
|
||||
|
||||
_, err := c.AddFunc("0 */5 * * * *", func() {
|
||||
runReminderScan(db)
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] add reminder scan job failed: %v", err)
|
||||
return c
|
||||
}
|
||||
|
||||
_, err = c.AddFunc("0 10 2 * * *", func() {
|
||||
runCompensationScan(db)
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] add compensation job failed: %v", err)
|
||||
return c
|
||||
}
|
||||
|
||||
c.Start()
|
||||
log.Println("[scheduler] reminder scan started: every 5 minutes, compensation daily 02:10")
|
||||
return c
|
||||
}
|
||||
|
||||
func runReminderScan(db *gorm.DB) {
|
||||
now := time.Now().UTC()
|
||||
windowEnd := now.Add(5 * time.Minute)
|
||||
|
||||
var pending []model.Reminder
|
||||
qStart := time.Now()
|
||||
err := db.Where("status = ? AND remind_at <= ?", "pending", windowEnd).Order("status asc, remind_at asc").Limit(200).Find(&pending).Error
|
||||
metrics.ObserveDB("scan_pending", "reminders", err == nil, time.Since(qStart))
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] pending reminder query error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, r := range pending {
|
||||
processReminder(db, r, now)
|
||||
}
|
||||
|
||||
var failed []model.Reminder
|
||||
qStart = time.Now()
|
||||
err = db.Where("status = ? AND next_retry_at IS NOT NULL AND next_retry_at <= ?", "failed", now).Order("status asc, next_retry_at asc").Limit(200).Find(&failed).Error
|
||||
metrics.ObserveDB("scan_failed", "reminders", err == nil, time.Since(qStart))
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] failed reminder query error: %v", err)
|
||||
return
|
||||
}
|
||||
for _, r := range failed {
|
||||
processReminder(db, r, now)
|
||||
}
|
||||
}
|
||||
|
||||
func processReminder(db *gorm.DB, r model.Reminder, now time.Time) {
|
||||
claim := db.Model(&model.Reminder{}).Where("id = ? AND status IN ?", r.ID, []string{"pending", "failed"}).Updates(map[string]interface{}{
|
||||
"status": "sending",
|
||||
"last_error": "",
|
||||
"next_retry_at": nil,
|
||||
})
|
||||
if claim.Error != nil {
|
||||
log.Printf("[scheduler] claim reminder id=%d failed: %v", r.ID, claim.Error)
|
||||
return
|
||||
}
|
||||
if claim.RowsAffected == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if err := deliverReminder(r); err != nil {
|
||||
metrics.ReminderSendTotal.WithLabelValues("failed").Inc()
|
||||
retryCount := r.RetryCount + 1
|
||||
if retryCount >= maxRetryCount() {
|
||||
_ = db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.Reminder{}).Where("id = ?", r.ID).Updates(map[string]interface{}{
|
||||
"status": "failed",
|
||||
"retry_count": retryCount,
|
||||
"last_error": "retry limit reached: " + err.Error(),
|
||||
"next_retry_at": nil,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
dl := model.ReminderDeadLetter{
|
||||
ReminderID: r.ID,
|
||||
UserID: r.UserID,
|
||||
AssetID: r.AssetID,
|
||||
RemindAt: r.RemindAt,
|
||||
Channel: r.Channel,
|
||||
Status: "failed",
|
||||
RetryCount: retryCount,
|
||||
LastError: "retry limit reached: " + err.Error(),
|
||||
}
|
||||
return tx.Where("reminder_id = ?", r.ID).FirstOrCreate(&dl).Error
|
||||
})
|
||||
return
|
||||
}
|
||||
metrics.ReminderRetryTotal.Inc()
|
||||
next := now.Add(retryDelay(retryCount))
|
||||
_ = db.Model(&model.Reminder{}).Where("id = ?", r.ID).Updates(map[string]interface{}{
|
||||
"status": "failed",
|
||||
"retry_count": retryCount,
|
||||
"next_retry_at": &next,
|
||||
"last_error": err.Error(),
|
||||
}).Error
|
||||
return
|
||||
}
|
||||
|
||||
metrics.ReminderSendTotal.WithLabelValues("sent").Inc()
|
||||
sentAt := now
|
||||
_ = db.Model(&model.Reminder{}).Where("id = ?", r.ID).Updates(map[string]interface{}{
|
||||
"status": "sent",
|
||||
"sent_at": &sentAt,
|
||||
"last_error": "",
|
||||
"next_retry_at": nil,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func runCompensationScan(db *gorm.DB) {
|
||||
now := time.Now().UTC()
|
||||
cutoff := now.Add(-10 * time.Minute)
|
||||
var missed []model.Reminder
|
||||
qStart := time.Now()
|
||||
err := db.Where("status = ? AND remind_at <= ?", "pending", cutoff).Order("remind_at asc").Limit(500).Find(&missed).Error
|
||||
metrics.ObserveDB("compensation_scan", "reminders", err == nil, time.Since(qStart))
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] compensation query error: %v", err)
|
||||
return
|
||||
}
|
||||
if len(missed) > 0 {
|
||||
log.Printf("[scheduler] compensation scan found %d pending overdue reminders", len(missed))
|
||||
}
|
||||
for _, r := range missed {
|
||||
processReminder(db, r, now)
|
||||
}
|
||||
}
|
||||
|
||||
func deliverReminder(r model.Reminder) error {
|
||||
if r.Channel != "in_app" {
|
||||
return errors.New("unsupported channel")
|
||||
}
|
||||
log.Printf("[reminder] user=%d asset=%d channel=%s remind_at=%s dedupe=%s", r.UserID, r.AssetID, r.Channel, r.RemindAt.Format(time.RFC3339), r.DedupeKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func retryDelay(retryCount int) time.Duration {
|
||||
switch retryCount {
|
||||
case 1:
|
||||
return 5 * time.Minute
|
||||
case 2:
|
||||
return 30 * time.Minute
|
||||
default:
|
||||
return 2 * time.Hour
|
||||
}
|
||||
}
|
||||
|
||||
func maxRetryCount() int {
|
||||
return 8
|
||||
}
|
||||
Reference in New Issue
Block a user