Compare commits
5 Commits
5776c00dce
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
772cb0e238 | ||
|
|
5ad2a8a33e | ||
|
|
ae2ac23a3a | ||
|
|
94dd530823 | ||
|
|
e00aa69369 |
24
.gitea/scripts/hygiene-check.sh
Normal file
24
.gitea/scripts/hygiene-check.sh
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
max_bytes=$((50 * 1024 * 1024))
|
||||||
|
failed=0
|
||||||
|
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
case "$file" in
|
||||||
|
.env|*/.env|*.DS_Store|*/node_modules/*|*.tmp|*.temp|*.bak|*.orig|*.rej|*.zip|*.tar|*.tar.gz|*.tgz|*.rar|*.7z)
|
||||||
|
echo "Forbidden tracked file: $file" >&2
|
||||||
|
failed=1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
size=$(wc -c < "$file")
|
||||||
|
if [ "$size" -gt "$max_bytes" ]; then
|
||||||
|
echo "Tracked file is larger than 50 MiB: $file ($size bytes)" >&2
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done < <(git ls-files -z)
|
||||||
|
|
||||||
|
exit "$failed"
|
||||||
@@ -5,8 +5,15 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
hygiene:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: bash .gitea/scripts/hygiene-check.sh
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: hygiene
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
chimw "github.com/go-chi/chi/v5/middleware"
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
commonaudit "gitea.estateliga.work/admin/portal-common/audit"
|
||||||
commondb "gitea.estateliga.work/admin/portal-common/db"
|
commondb "gitea.estateliga.work/admin/portal-common/db"
|
||||||
commonmw "gitea.estateliga.work/admin/portal-common/middleware"
|
commonmw "gitea.estateliga.work/admin/portal-common/middleware"
|
||||||
|
|
||||||
@@ -72,8 +73,13 @@ func main() {
|
|||||||
lessonRepo := repository.NewLessonRepository(pool)
|
lessonRepo := repository.NewLessonRepository(pool)
|
||||||
publicTokenRepo := repository.NewPublicTokenRepository(pool)
|
publicTokenRepo := repository.NewPublicTokenRepository(pool)
|
||||||
accessRepo := repository.NewAccessGrantRepository(pool)
|
accessRepo := repository.NewAccessGrantRepository(pool)
|
||||||
|
auditClient := commonaudit.NewClient(cfg.PortalURL, cfg.InternalAPIKey)
|
||||||
|
if !auditClient.Enabled() {
|
||||||
|
slog.Warn("portal audit client disabled — business audit events выключены",
|
||||||
|
"portal_url_set", cfg.PortalURL != "", "portal_key_set", cfg.InternalAPIKey != "")
|
||||||
|
}
|
||||||
|
|
||||||
healthH := handler.NewHealthHandler(pool)
|
healthH := handler.NewHealthHandler(pool, store)
|
||||||
testH := handler.NewTestHandler(testRepo, accessRepo)
|
testH := handler.NewTestHandler(testRepo, accessRepo)
|
||||||
attemptH := handler.NewAttemptHandler(attemptRepo, testRepo)
|
attemptH := handler.NewAttemptHandler(attemptRepo, testRepo)
|
||||||
courseH := handler.NewCourseHandler(courseRepo, accessRepo)
|
courseH := handler.NewCourseHandler(courseRepo, accessRepo)
|
||||||
@@ -88,9 +94,11 @@ func main() {
|
|||||||
|
|
||||||
r.Get("/healthz", healthH.Healthz)
|
r.Get("/healthz", healthH.Healthz)
|
||||||
r.Get("/readyz", healthH.Readyz)
|
r.Get("/readyz", healthH.Readyz)
|
||||||
|
r.Get("/health/detail", healthH.Detail)
|
||||||
|
|
||||||
r.Route("/api", func(r chi.Router) {
|
r.Route("/api", func(r chi.Router) {
|
||||||
r.Use(commonmw.InternalAuth(cfg.InternalAPIKey))
|
r.Use(commonmw.InternalAuth(cfg.InternalAPIKey))
|
||||||
|
r.Use(handler.NewAuditMiddleware(auditClient).Middleware)
|
||||||
|
|
||||||
// Tests CRUD
|
// Tests CRUD
|
||||||
r.Get("/tests", testH.List)
|
r.Get("/tests", testH.List)
|
||||||
@@ -192,12 +200,3 @@ func main() {
|
|||||||
}
|
}
|
||||||
slog.Info("server stopped")
|
slog.Info("server stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
// notImplemented — заглушка для эндпоинтов, которые есть в схеме, но
|
|
||||||
// ещё не имплементированы. Возвращает 501 + понятное сообщение, чтобы
|
|
||||||
// фронт мог отрендерить «в разработке» вместо generic 500.
|
|
||||||
func notImplemented(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusNotImplemented)
|
|
||||||
_, _ = w.Write([]byte(`{"error":"not implemented yet — следующая итерация"}`))
|
|
||||||
}
|
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -3,7 +3,7 @@ module learning-service
|
|||||||
go 1.25.7
|
go 1.25.7
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.estateliga.work/admin/portal-common v0.2.0
|
gitea.estateliga.work/admin/portal-common v0.3.0
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.9.1
|
github.com/jackc/pgx/v5 v5.9.1
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -1,5 +1,5 @@
|
|||||||
gitea.estateliga.work/admin/portal-common v0.2.0 h1:TwSxTDwSWnPJUGuCfjSy1f++MxvDIZ+HCUNMC3EFNcE=
|
gitea.estateliga.work/admin/portal-common v0.3.0 h1:xpr9UeLXk5pCcNXcTVGZzJZr0Ni7An7DV0OkuYv9qVM=
|
||||||
gitea.estateliga.work/admin/portal-common v0.2.0/go.mod h1:C860q6g38KVMsv+mKv6k1Vm7smVRCycl+N6r63TElnk=
|
gitea.estateliga.work/admin/portal-common v0.3.0/go.mod h1:C860q6g38KVMsv+mKv6k1Vm7smVRCycl+N6r63TElnk=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
|||||||
279
internal/handler/audit.go
Normal file
279
internal/handler/audit.go
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
commonaudit "gitea.estateliga.work/admin/portal-common/audit"
|
||||||
|
commonmw "gitea.estateliga.work/admin/portal-common/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditMiddleware struct {
|
||||||
|
client *commonaudit.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuditMiddleware(client *commonaudit.Client) *AuditMiddleware {
|
||||||
|
return &AuditMiddleware{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AuditMiddleware) Middleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if m == nil || m.client == nil || !m.client.Enabled() || !isAuditWriteMethod(r.Method) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := &auditResponseRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||||
|
start := time.Now()
|
||||||
|
next.ServeHTTP(rec, r)
|
||||||
|
if rec.status < 200 || rec.status >= 300 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
action := matchLearningAuditAction(r.Method, r.URL.Path)
|
||||||
|
if action.Action == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if action.EntityID == "" {
|
||||||
|
action.EntityID = extractAuditEntityID(rec.body.Bytes())
|
||||||
|
}
|
||||||
|
details := map[string]any{
|
||||||
|
"method": r.Method,
|
||||||
|
"path": r.URL.Path,
|
||||||
|
"status": rec.status,
|
||||||
|
"duration_ms": time.Since(start).Milliseconds(),
|
||||||
|
}
|
||||||
|
if rid := chimw.GetReqID(r.Context()); rid != "" {
|
||||||
|
details["request_id"] = rid
|
||||||
|
}
|
||||||
|
if q := r.URL.RawQuery; q != "" {
|
||||||
|
details["query"] = q
|
||||||
|
}
|
||||||
|
|
||||||
|
event := commonaudit.Event{
|
||||||
|
Action: action.Action,
|
||||||
|
EntityType: action.EntityType,
|
||||||
|
EntityID: action.EntityID,
|
||||||
|
UserID: commonmw.GetUserID(r.Context()),
|
||||||
|
UserName: commonmw.GetUserName(r.Context()),
|
||||||
|
IPAddress: commonmw.GetClientIP(r.Context()),
|
||||||
|
Details: details,
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := m.client.Send(context.Background(), event); err != nil {
|
||||||
|
slog.Warn("learning audit send failed", "error", err, "action", event.Action, "entity_id", event.EntityID)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type learningAuditAction struct {
|
||||||
|
Action string
|
||||||
|
EntityType string
|
||||||
|
EntityID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type auditResponseRecorder struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
status int
|
||||||
|
body bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *auditResponseRecorder) WriteHeader(status int) {
|
||||||
|
r.status = status
|
||||||
|
r.ResponseWriter.WriteHeader(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *auditResponseRecorder) Write(body []byte) (int, error) {
|
||||||
|
if r.body.Len() < 64*1024 {
|
||||||
|
_, _ = r.body.Write(body)
|
||||||
|
}
|
||||||
|
return r.ResponseWriter.Write(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAuditWriteMethod(method string) bool {
|
||||||
|
switch method {
|
||||||
|
case http.MethodPost, http.MethodPatch, http.MethodPut, http.MethodDelete:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchLearningAuditAction(method, path string) learningAuditAction {
|
||||||
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
|
if len(parts) > 0 && parts[0] == "api" {
|
||||||
|
parts = parts[1:]
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return learningAuditAction{}
|
||||||
|
}
|
||||||
|
switch parts[0] {
|
||||||
|
case "tests":
|
||||||
|
return matchTestAuditAction(method, parts)
|
||||||
|
case "attempts":
|
||||||
|
return matchAttemptAuditAction(method, parts)
|
||||||
|
case "courses":
|
||||||
|
return matchCourseAuditAction(method, parts)
|
||||||
|
case "lessons":
|
||||||
|
return matchLessonAuditAction(method, parts)
|
||||||
|
case "access":
|
||||||
|
return matchAccessAuditAction(method, parts)
|
||||||
|
case "public-tokens":
|
||||||
|
return matchPublicTokenAuditAction(method, parts)
|
||||||
|
default:
|
||||||
|
return learningAuditAction{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchTestAuditAction(method string, parts []string) learningAuditAction {
|
||||||
|
if method == http.MethodPost && len(parts) == 1 {
|
||||||
|
return learningAuditAction{"learning.test_create", "learning_test", ""}
|
||||||
|
}
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return learningAuditAction{}
|
||||||
|
}
|
||||||
|
testID := parts[1]
|
||||||
|
if len(parts) == 2 {
|
||||||
|
switch method {
|
||||||
|
case http.MethodPatch:
|
||||||
|
return learningAuditAction{"learning.test_update", "learning_test", testID}
|
||||||
|
case http.MethodDelete:
|
||||||
|
return learningAuditAction{"learning.test_delete", "learning_test", testID}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
switch parts[2] {
|
||||||
|
case "questions":
|
||||||
|
return matchQuestionAuditAction(method, parts, testID)
|
||||||
|
case "attempts":
|
||||||
|
if method == http.MethodPost && len(parts) == 3 {
|
||||||
|
return learningAuditAction{"learning.attempt_start", "learning_test", testID}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return learningAuditAction{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchQuestionAuditAction(method string, parts []string, testID string) learningAuditAction {
|
||||||
|
if method == http.MethodPost && len(parts) == 3 {
|
||||||
|
return learningAuditAction{"learning.question_create", "learning_test", testID}
|
||||||
|
}
|
||||||
|
if method == http.MethodPost && len(parts) == 4 && parts[3] == "reorder" {
|
||||||
|
return learningAuditAction{"learning.question_reorder", "learning_test", testID}
|
||||||
|
}
|
||||||
|
if len(parts) == 4 {
|
||||||
|
switch method {
|
||||||
|
case http.MethodPut:
|
||||||
|
return learningAuditAction{"learning.question_update", "learning_question", parts[3]}
|
||||||
|
case http.MethodDelete:
|
||||||
|
return learningAuditAction{"learning.question_delete", "learning_question", parts[3]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return learningAuditAction{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchAttemptAuditAction(method string, parts []string) learningAuditAction {
|
||||||
|
if method == http.MethodPost && len(parts) == 3 && parts[2] == "submit" {
|
||||||
|
return learningAuditAction{"learning.attempt_submit", "learning_attempt", parts[1]}
|
||||||
|
}
|
||||||
|
return learningAuditAction{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchCourseAuditAction(method string, parts []string) learningAuditAction {
|
||||||
|
if method == http.MethodPost && len(parts) == 1 {
|
||||||
|
return learningAuditAction{"learning.course_create", "learning_course", ""}
|
||||||
|
}
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return learningAuditAction{}
|
||||||
|
}
|
||||||
|
courseID := parts[1]
|
||||||
|
if len(parts) == 2 {
|
||||||
|
switch method {
|
||||||
|
case http.MethodPatch:
|
||||||
|
return learningAuditAction{"learning.course_update", "learning_course", courseID}
|
||||||
|
case http.MethodDelete:
|
||||||
|
return learningAuditAction{"learning.course_delete", "learning_course", courseID}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if method == http.MethodPost && len(parts) == 3 && parts[2] == "lessons" {
|
||||||
|
return learningAuditAction{"learning.lesson_create", "learning_course", courseID}
|
||||||
|
}
|
||||||
|
if method == http.MethodPost && len(parts) == 4 && parts[2] == "lessons" && parts[3] == "reorder" {
|
||||||
|
return learningAuditAction{"learning.lesson_reorder", "learning_course", courseID}
|
||||||
|
}
|
||||||
|
return learningAuditAction{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchLessonAuditAction(method string, parts []string) learningAuditAction {
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return learningAuditAction{}
|
||||||
|
}
|
||||||
|
lessonID := parts[1]
|
||||||
|
if len(parts) == 2 {
|
||||||
|
switch method {
|
||||||
|
case http.MethodPatch:
|
||||||
|
return learningAuditAction{"learning.lesson_update", "learning_lesson", lessonID}
|
||||||
|
case http.MethodDelete:
|
||||||
|
return learningAuditAction{"learning.lesson_delete", "learning_lesson", lessonID}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(parts) == 3 && parts[2] == "video" {
|
||||||
|
switch method {
|
||||||
|
case http.MethodPost:
|
||||||
|
return learningAuditAction{"learning.lesson_video_upload", "learning_lesson", lessonID}
|
||||||
|
case http.MethodDelete:
|
||||||
|
return learningAuditAction{"learning.lesson_video_delete", "learning_lesson", lessonID}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return learningAuditAction{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchAccessAuditAction(method string, parts []string) learningAuditAction {
|
||||||
|
entityID := strings.Join(parts, "/")
|
||||||
|
if method == http.MethodPost && len(parts) == 3 {
|
||||||
|
return learningAuditAction{"learning.access_grant_create", "learning_access", entityID}
|
||||||
|
}
|
||||||
|
if method == http.MethodDelete && len(parts) == 5 && parts[3] == "grants" {
|
||||||
|
return learningAuditAction{"learning.access_grant_delete", "learning_access_grant", parts[4]}
|
||||||
|
}
|
||||||
|
return learningAuditAction{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchPublicTokenAuditAction(method string, parts []string) learningAuditAction {
|
||||||
|
if method == http.MethodPost && len(parts) == 1 {
|
||||||
|
return learningAuditAction{"learning.public_token_create", "learning_public_token", ""}
|
||||||
|
}
|
||||||
|
if method == http.MethodDelete && len(parts) == 2 {
|
||||||
|
return learningAuditAction{"learning.public_token_revoke", "learning_public_token", parts[1]}
|
||||||
|
}
|
||||||
|
return learningAuditAction{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractAuditEntityID(body []byte) string {
|
||||||
|
if len(body) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
ID any `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch id := payload.ID.(type) {
|
||||||
|
case string:
|
||||||
|
return id
|
||||||
|
case float64:
|
||||||
|
return strconv.FormatInt(int64(id), 10)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
117
internal/handler/audit_test.go
Normal file
117
internal/handler/audit_test.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMatchLearningAuditAction(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
action string
|
||||||
|
entityType string
|
||||||
|
entityID string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "test create",
|
||||||
|
method: http.MethodPost,
|
||||||
|
path: "/api/tests",
|
||||||
|
action: "learning.test_create",
|
||||||
|
entityType: "learning_test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "question reorder",
|
||||||
|
method: http.MethodPost,
|
||||||
|
path: "/api/tests/7/questions/reorder",
|
||||||
|
action: "learning.question_reorder",
|
||||||
|
entityType: "learning_test",
|
||||||
|
entityID: "7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "question update",
|
||||||
|
method: http.MethodPut,
|
||||||
|
path: "/api/tests/7/questions/9",
|
||||||
|
action: "learning.question_update",
|
||||||
|
entityType: "learning_question",
|
||||||
|
entityID: "9",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "attempt submit",
|
||||||
|
method: http.MethodPost,
|
||||||
|
path: "/api/attempts/11/submit",
|
||||||
|
action: "learning.attempt_submit",
|
||||||
|
entityType: "learning_attempt",
|
||||||
|
entityID: "11",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "course update",
|
||||||
|
method: http.MethodPatch,
|
||||||
|
path: "/api/courses/5",
|
||||||
|
action: "learning.course_update",
|
||||||
|
entityType: "learning_course",
|
||||||
|
entityID: "5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lesson create",
|
||||||
|
method: http.MethodPost,
|
||||||
|
path: "/api/courses/5/lessons",
|
||||||
|
action: "learning.lesson_create",
|
||||||
|
entityType: "learning_course",
|
||||||
|
entityID: "5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lesson video upload",
|
||||||
|
method: http.MethodPost,
|
||||||
|
path: "/api/lessons/3/video",
|
||||||
|
action: "learning.lesson_video_upload",
|
||||||
|
entityType: "learning_lesson",
|
||||||
|
entityID: "3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "access grant create",
|
||||||
|
method: http.MethodPost,
|
||||||
|
path: "/api/access/course/5",
|
||||||
|
action: "learning.access_grant_create",
|
||||||
|
entityType: "learning_access",
|
||||||
|
entityID: "access/course/5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "public token revoke",
|
||||||
|
method: http.MethodDelete,
|
||||||
|
path: "/api/public-tokens/13",
|
||||||
|
action: "learning.public_token_revoke",
|
||||||
|
entityType: "learning_public_token",
|
||||||
|
entityID: "13",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := matchLearningAuditAction(tt.method, tt.path)
|
||||||
|
if got.Action != tt.action || got.EntityType != tt.entityType || got.EntityID != tt.entityID {
|
||||||
|
t.Fatalf("unexpected action: got %#v, want action=%q entityType=%q entityID=%q", got, tt.action, tt.entityType, tt.entityID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchLearningAuditActionUnknown(t *testing.T) {
|
||||||
|
got := matchLearningAuditAction(http.MethodPost, "/public/learning/tokens/token/resolve")
|
||||||
|
if got.Action != "" {
|
||||||
|
t.Fatalf("expected no audit action, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractAuditEntityID(t *testing.T) {
|
||||||
|
if got := extractAuditEntityID([]byte(`{"id":42}`)); got != "42" {
|
||||||
|
t.Fatalf("numeric id = %q, want 42", got)
|
||||||
|
}
|
||||||
|
if got := extractAuditEntityID([]byte(`{"id":"abc"}`)); got != "abc" {
|
||||||
|
t.Fatalf("string id = %q, want abc", got)
|
||||||
|
}
|
||||||
|
if got := extractAuditEntityID([]byte(`{"title":"missing"}`)); got != "" {
|
||||||
|
t.Fatalf("missing id = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,17 +3,21 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"learning-service/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HealthHandler struct {
|
type HealthHandler struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
|
store *storage.Storage
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHealthHandler(pool *pgxpool.Pool) *HealthHandler {
|
func NewHealthHandler(pool *pgxpool.Pool, store *storage.Storage) *HealthHandler {
|
||||||
return &HealthHandler{pool: pool}
|
return &HealthHandler{pool: pool, store: store}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Healthz — liveness. Не дёргает БД; жив если процесс отвечает.
|
// Healthz — liveness. Не дёргает БД; жив если процесс отвечает.
|
||||||
@@ -32,3 +36,65 @@ func (h *HealthHandler) Readyz(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *HealthHandler) Detail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
components := []componentProbe{
|
||||||
|
h.probePostgres(ctx),
|
||||||
|
h.probeVideoStorage(ctx),
|
||||||
|
h.probeVideoMetadata(ctx),
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"components": components})
|
||||||
|
}
|
||||||
|
|
||||||
|
type componentProbe struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
LatencyMs int64 `json:"latency_ms"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HealthHandler) probePostgres(ctx context.Context) componentProbe {
|
||||||
|
start := time.Now()
|
||||||
|
if err := h.pool.Ping(ctx); err != nil {
|
||||||
|
return componentProbe{Name: "postgres", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
|
||||||
|
}
|
||||||
|
return componentProbe{Name: "postgres", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HealthHandler) probeVideoStorage(ctx context.Context) componentProbe {
|
||||||
|
start := time.Now()
|
||||||
|
if err := h.store.Check(ctx); err != nil {
|
||||||
|
return componentProbe{Name: "video_storage", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
|
||||||
|
}
|
||||||
|
return componentProbe{Name: "video_storage", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HealthHandler) probeVideoMetadata(ctx context.Context) componentProbe {
|
||||||
|
start := time.Now()
|
||||||
|
var videos, broken int
|
||||||
|
err := h.pool.QueryRow(ctx, `
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE video_key <> '')::int,
|
||||||
|
COUNT(*) FILTER (WHERE video_key <> '' AND btrim(video_key) = '')::int
|
||||||
|
FROM lessons`,
|
||||||
|
).Scan(&videos, &broken)
|
||||||
|
if err != nil {
|
||||||
|
return componentProbe{Name: "video_metadata", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
|
||||||
|
}
|
||||||
|
if broken > 0 {
|
||||||
|
return componentProbe{
|
||||||
|
Name: "video_metadata",
|
||||||
|
Status: "down",
|
||||||
|
LatencyMs: time.Since(start).Milliseconds(),
|
||||||
|
Error: "videos=" + intString(videos) + " broken_video_keys=" + intString(broken),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return componentProbe{Name: "video_metadata", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func intString(v int) string {
|
||||||
|
return strconv.Itoa(v)
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,6 +72,20 @@ func (s *Storage) EnsureBucket(ctx context.Context) error {
|
|||||||
return s.client.MakeBucket(ctx, s.cfg.Bucket, minio.MakeBucketOptions{})
|
return s.client.MakeBucket(ctx, s.cfg.Bucket, minio.MakeBucketOptions{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Storage) Check(ctx context.Context) error {
|
||||||
|
if !s.Configured() {
|
||||||
|
return errors.New("storage not configured")
|
||||||
|
}
|
||||||
|
exists, err := s.client.BucketExists(ctx, s.cfg.Bucket)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check bucket: %w", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("bucket not found: %s", s.cfg.Bucket)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GenerateKey — путь объекта в bucket'е. Структура: <lesson_id>/<random>.<ext>,
|
// GenerateKey — путь объекта в bucket'е. Структура: <lesson_id>/<random>.<ext>,
|
||||||
// где random — короткий uuid для anti-cache + защита от перезаписи случайно.
|
// где random — короткий uuid для anti-cache + защита от перезаписи случайно.
|
||||||
// При замене видео ставится новый key, старый объект остаётся в MinIO
|
// При замене видео ставится новый key, старый объект остаётся в MinIO
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ metadata:
|
|||||||
name: learning-config
|
name: learning-config
|
||||||
namespace: learning
|
namespace: learning
|
||||||
data:
|
data:
|
||||||
DATABASE_URL: "postgres://learning:learning@postgres.learning.svc.cluster.local:5432/learning?sslmode=disable"
|
|
||||||
SERVER_PORT: "3001"
|
SERVER_PORT: "3001"
|
||||||
# PORTAL_URL — для in-app уведомлений HR'у когда кандидат проходит тест,
|
# PORTAL_URL — для in-app уведомлений HR'у когда кандидат проходит тест,
|
||||||
# сотруднику когда ему назначили курс. Пустой = notify-вызовы no-op'ятся.
|
# сотруднику когда ему назначили курс. Пустой = notify-вызовы no-op'ятся.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ metadata:
|
|||||||
namespace: learning
|
namespace: learning
|
||||||
type: Opaque
|
type: Opaque
|
||||||
stringData:
|
stringData:
|
||||||
|
DATABASE_URL: "postgres://learning:learning@postgres.learning.svc.cluster.local:5432/learning?sslmode=disable"
|
||||||
PORTAL_INTERNAL_API_KEY: "36fe89ed40c01fdc54d3cf4e3fcacc8751dc456a4a1acd394e9fed48257c5734"
|
PORTAL_INTERNAL_API_KEY: "36fe89ed40c01fdc54d3cf4e3fcacc8751dc456a4a1acd394e9fed48257c5734"
|
||||||
INTERNAL_API_KEY: "36fe89ed40c01fdc54d3cf4e3fcacc8751dc456a4a1acd394e9fed48257c5734"
|
INTERNAL_API_KEY: "36fe89ed40c01fdc54d3cf4e3fcacc8751dc456a4a1acd394e9fed48257c5734"
|
||||||
MINIO_ACCESS_KEY: "learning-svc"
|
MINIO_ACCESS_KEY: "learning-svc"
|
||||||
|
|||||||
Reference in New Issue
Block a user