Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a78a93f2f0 | ||
|
|
3e8722a26b | ||
|
|
7e29c53ce9 | ||
|
|
60c8aa31ac | ||
|
|
92ff87a917 | ||
|
|
ee876f1acd | ||
|
|
bc0e1ab1b4 | ||
| 3d798c13e3 | |||
|
|
3f685d3e22 | ||
|
|
85f61436b6 | ||
|
|
39c58440cf | ||
|
|
ae0dd4c8b3 | ||
|
|
f7826fb230 | ||
|
|
96e055e13f |
1
.agents/skills/graphify/.graphify_version
Normal file
1
.agents/skills/graphify/.graphify_version
Normal file
@@ -0,0 +1 @@
|
||||
0.9.5
|
||||
674
.agents/skills/graphify/SKILL.md
Normal file
674
.agents/skills/graphify/SKILL.md
Normal file
@@ -0,0 +1,674 @@
|
||||
---
|
||||
name: graphify
|
||||
description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
|
||||
---
|
||||
|
||||
# /graphify
|
||||
|
||||
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/graphify # full pipeline on current directory → Obsidian vault
|
||||
/graphify <path> # full pipeline on specific path
|
||||
/graphify https://github.com/<owner>/<repo> # clone repo then run full pipeline on it
|
||||
/graphify https://github.com/<owner>/<repo> --branch <branch> # clone a specific branch
|
||||
/graphify <url1> <url2> ... # clone multiple repos, build each, merge into one cross-repo graph
|
||||
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
|
||||
/graphify <path> --update # incremental - re-extract only new/changed files
|
||||
/graphify <path> --directed # build directed graph (preserves edge direction: source→target)
|
||||
/graphify <path> --whisper-model medium # use a larger Whisper model for better transcription accuracy
|
||||
/graphify <path> --cluster-only # rerun clustering on existing graph
|
||||
/graphify <path> --no-viz # skip visualization, just report + JSON
|
||||
/graphify <path> --html # (HTML is generated by default - this flag is a no-op)
|
||||
/graphify <path> --svg # also export graph.svg (embeds in Notion, GitHub)
|
||||
/graphify <path> --graphml # export graph.graphml (Gephi, yEd)
|
||||
/graphify <path> --neo4j # generate graphify-out/cypher.txt for Neo4j
|
||||
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
|
||||
/graphify <path> --falkordb # generate graphify-out/cypher.txt for FalkorDB
|
||||
/graphify <path> --falkordb-push falkordb://localhost:6379 # push directly to FalkorDB
|
||||
/graphify <path> --mcp # start MCP stdio server for agent access
|
||||
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
|
||||
/graphify <path> --wiki # build agent-crawlable wiki (index.md + one article per community)
|
||||
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
|
||||
/graphify add <url> # fetch URL, save to ./raw, update graph
|
||||
/graphify add <url> --author "Name" # tag who wrote it
|
||||
/graphify add <url> --contributor "Name" # tag who added it to the corpus
|
||||
/graphify query "<question>" # BFS traversal - broad context
|
||||
/graphify query "<question>" --dfs # DFS - trace a specific path
|
||||
/graphify query "<question>" --budget 1500 # cap answer at N tokens
|
||||
/graphify path "AuthModule" "Database" # shortest path between two concepts
|
||||
/graphify explain "SwinTransformer" # plain-language explanation of a node
|
||||
```
|
||||
|
||||
## What graphify is for
|
||||
|
||||
Drop any folder of code, docs, papers, images, or video into graphify and get a queryable knowledge graph. Persistent across sessions, honest audit trail (EXTRACTED/INFERRED/AMBIGUOUS), community detection surfaces cross-document connections you wouldn't think to ask about.
|
||||
|
||||
## What You Must Do When Invoked
|
||||
|
||||
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
|
||||
|
||||
**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query "<question>"` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it.
|
||||
|
||||
If no path was given, use `.` (current directory). Do not ask the user for a path.
|
||||
|
||||
If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path.
|
||||
|
||||
Follow these steps in order. Do not skip steps.
|
||||
|
||||
### Step 0 - GitHub repos and multi-path merge (only if a URL or several paths)
|
||||
|
||||
Only when the path is one or more `https://github.com/...` URLs, or several local subfolders to merge. See `references/github-and-merge.md` for the clone, cross-repo merge, and monorepo flow, then continue with the resolved local path. A plain local path skips this step.
|
||||
|
||||
### Step 1 - Ensure graphify is installed
|
||||
|
||||
```bash
|
||||
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs)
|
||||
PYTHON=""
|
||||
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
|
||||
# 1. uv tool installs — most reliable on modern Mac/Linux
|
||||
if [ -z "$PYTHON" ] && command -v uv >/dev/null 2>&1; then
|
||||
_UV_PY=$(uv tool run graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
|
||||
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
|
||||
fi
|
||||
# 2. Read shebang from graphify binary (pipx and direct pip installs)
|
||||
if [ -z "$PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then
|
||||
_SHEBANG=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
|
||||
case "$_SHEBANG" in
|
||||
*[!a-zA-Z0-9/_.@-]*) ;;
|
||||
*) "$_SHEBANG" -c "import graphify" 2>/dev/null && PYTHON="$_SHEBANG" ;;
|
||||
esac
|
||||
fi
|
||||
# 3. Fall back to python3
|
||||
if [ -z "$PYTHON" ]; then PYTHON="python3"; fi
|
||||
if ! "$PYTHON" -c "import graphify" 2>/dev/null; then
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
uv tool install --upgrade graphifyy -q 2>&1 | tail -3
|
||||
_UV_PY=$(uv tool run graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
|
||||
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
|
||||
else
|
||||
"$PYTHON" -m pip install graphifyy -q 2>/dev/null \
|
||||
|| "$PYTHON" -m pip install graphifyy -q --break-system-packages 2>&1 | tail -3
|
||||
fi
|
||||
fi
|
||||
# Write interpreter path for all subsequent steps (persists across invocations)
|
||||
mkdir -p graphify-out
|
||||
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
|
||||
# Save scan root so `graphify update` (no args) knows where to look next time
|
||||
echo "$(cd INPUT_PATH && pwd)" > graphify-out/.graphify_root
|
||||
```
|
||||
|
||||
If the import succeeds, print nothing and move straight to Step 2.
|
||||
|
||||
**In every subsequent bash block, replace `python3` with `$(cat graphify-out/.graphify_python)` to use the correct interpreter.**
|
||||
|
||||
### Step 2 - Detect files
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from graphify.detect import detect
|
||||
from pathlib import Path
|
||||
result = detect(Path('INPUT_PATH'))
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
" > graphify-out/.graphify_detect.json
|
||||
```
|
||||
|
||||
Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead:
|
||||
|
||||
```
|
||||
Corpus: X files · ~Y words
|
||||
code: N files (.py .ts .go ...)
|
||||
docs: N files (.md .txt ...)
|
||||
papers: N files (.pdf ...)
|
||||
images: N files
|
||||
video: N files (.mp4 .mp3 ...)
|
||||
```
|
||||
|
||||
Omit any category with 0 files from the summary.
|
||||
|
||||
Then act on it:
|
||||
- If `total_files` is 0: stop with "No supported files found in [path]."
|
||||
- If `skipped_sensitive` is non-empty: mention file count skipped, not the file names.
|
||||
- If `total_words` > 2,000,000 OR `total_files` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count:
|
||||
- Read `scan_root` from the detect JSON (always an absolute path to the resolved INPUT_PATH).
|
||||
- Concatenate all file lists across all types (`code`, `document`, `paper`, `image`, `video`).
|
||||
- Filter out any path that starts with `scan_root + "/graphify-out/"` to exclude converted sidecars.
|
||||
- For each file, strip the `scan_root` prefix and take the first path component. Files directly in `scan_root` with no subdirectory count as `(root)`.
|
||||
- If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed.
|
||||
- Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding.
|
||||
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
|
||||
|
||||
### Step 2.5 - Video and audio (only if video files detected)
|
||||
|
||||
Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3.
|
||||
|
||||
### Step 3 - Extract entities and relationships
|
||||
|
||||
**Before starting:** note whether `--mode deep` was given. You must pass `DEEP_MODE=true` to every subagent in Step B2 if it was. Track this from the original invocation - do not lose it.
|
||||
|
||||
This step has two parts: **structural extraction** (deterministic, free) and **semantic extraction** (LLM, costs tokens).
|
||||
|
||||
> **graphify needs no API key. Never ask the user for one, and never block on one.** Code is extracted structurally (AST) with no LLM and no key at all — a code-only corpus (the common `/graphify .` on a repo) skips semantic extraction entirely, so it needs nothing here: go straight to Part A and skip Part B. Semantic extraction (only for docs, papers, and images) uses Gemini **only if** `GEMINI_API_KEY`/`GOOGLE_API_KEY` is already set; otherwise the host agent itself is the LLM. graphify does **not** read `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or any other provider key. If you catch yourself about to prompt for, wait on, or stop because of a missing API key, that is a misread of this skill — proceed without one.
|
||||
|
||||
**Before semantic extraction:** check whether `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set. If neither is set, print this one-liner to the user:
|
||||
> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction (`pip install 'graphifyy[gemini]'`).
|
||||
|
||||
Print it once, then continue — do not wait for the user to supply a key. If `GEMINI_API_KEY` or `GOOGLE_API_KEY` IS set, use `graphify.llm.extract_corpus_parallel(files, backend="gemini")` for semantic extraction instead of dispatching subagents. The default Gemini model is `gemini-3-flash-preview`; set `GRAPHIFY_GEMINI_MODEL` or pass `--model` in headless CLI flows to override it.
|
||||
|
||||
> **No other API keys are read.** When `GEMINI_API_KEY`/`GOOGLE_API_KEY` are unset, semantic extraction falls to the host agent itself — the running session is the LLM. On a host that dispatches subagents (e.g. Claude Code), dispatch them as written in Part B. On a host that runs the CLI directly in a terminal and cannot dispatch subagents, do not stall: a code-only corpus has no semantic work, so write the empty semantic file (Part B "Fast path") and continue to Part C; for a corpus with docs/papers/images, either set a Gemini key or extract those inline yourself, but in no case prompt for `ANTHROPIC_API_KEY` — that prompt is a misread of this skill.
|
||||
|
||||
**Run Part A (AST) and Part B (semantic) in parallel. Dispatch all semantic subagents AND start AST extraction in the same message. Both can run simultaneously since they operate on different file types. Merge results in Part C as before.**
|
||||
|
||||
Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is deterministic and fast; start it while subagents are processing docs/papers.
|
||||
|
||||
#### Part A - Structural extraction for code files
|
||||
|
||||
For any code files detected, run AST extraction in parallel with Part B subagents:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import sys, json
|
||||
from graphify.extract import collect_files, extract
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
code_files = []
|
||||
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
|
||||
for f in detect.get('files', {}).get('code', []):
|
||||
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
|
||||
|
||||
if code_files:
|
||||
result = extract(code_files, cache_root=Path('INPUT_PATH'))
|
||||
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
|
||||
else:
|
||||
Path('graphify-out/.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print('No code files - skipping AST extraction')
|
||||
"
|
||||
```
|
||||
|
||||
#### Part B - Semantic extraction (parallel subagents)
|
||||
|
||||
**Fast path:** If detection found zero docs, papers, and images (code-only corpus), skip Part B entirely and go straight to Part C. AST handles code - there is nothing for semantic subagents to do. **First write an empty semantic file** so Part C's merge has its input (it reads `.graphify_semantic.json` unconditionally; without this a code-only run hits `FileNotFoundError`):
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
|
||||
"
|
||||
```
|
||||
|
||||
**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.**
|
||||
|
||||
Before dispatching subagents, print a timing estimate:
|
||||
- Load `total_words` and file counts from `graphify-out/.graphify_detect.json`
|
||||
- Estimate agents needed: `ceil(uncached_non_code_files / 22)` (chunk size is 20-25)
|
||||
- Estimate time: ~45s per agent batch (they run in parallel, so total ≈ 45s × ceil(agents/parallel_limit))
|
||||
- Print: "Semantic extraction: ~N files → X agents, estimated ~Ys"
|
||||
|
||||
**Step B0 - Check extraction cache first**
|
||||
|
||||
Before dispatching any subagents, check which files already have cached extraction results:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from graphify.cache import check_semantic_cache
|
||||
from pathlib import Path
|
||||
|
||||
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
|
||||
# Only content files go to semantic extraction. Code is already covered structurally
|
||||
# by the AST pass (Part A); flattening every category here makes subagents re-read
|
||||
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
|
||||
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]
|
||||
|
||||
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH')
|
||||
|
||||
# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
|
||||
# run so Part C never merges a stale .graphify_cached.json (#1392).
|
||||
if cached_nodes or cached_edges or cached_hyperedges:
|
||||
Path('graphify-out/.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}, ensure_ascii=False), encoding=\"utf-8\")
|
||||
else:
|
||||
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
|
||||
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
|
||||
print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction')
|
||||
"
|
||||
```
|
||||
|
||||
Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt`. If all files are cached, skip to Part C directly.
|
||||
|
||||
**Step B1 - Split into chunks**
|
||||
|
||||
Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks of 20-25 files each. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
|
||||
|
||||
**Step B2 - Dispatch ALL subagents in a single message (Codex)**
|
||||
|
||||
> **Codex platform:** Uses `spawn_agent` + `wait_agent` + `close_agent` instead of the Agent tool.
|
||||
> Requires `multi_agent = true` under `[features]` in `~/.codex/config.toml`.
|
||||
> If `spawn_agent` is unavailable, tell the user to add that config and restart Codex.
|
||||
|
||||
Call `spawn_agent` once per chunk — ALL in the same response so they run in parallel. Build the message by wrapping the extraction prompt in task-delegation framing:
|
||||
|
||||
```
|
||||
spawn_agent(agent_type="worker", message="Your task is to perform the following. Follow the instructions below exactly.\n\n<agent-instructions>\n[extraction prompt, with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE substituted]\n</agent-instructions>\n\nExecute this now. Output ONLY the structured JSON response.")
|
||||
```
|
||||
|
||||
After all agents are dispatched, collect results sequentially in memory:
|
||||
```
|
||||
result = wait_agent(handle); close_agent(handle) # repeat per handle
|
||||
```
|
||||
|
||||
Parse each result as JSON. Accumulate nodes/edges/hyperedges across all results and write to `graphify-out/.graphify_semantic_new.json`. Codex collects in memory, so there are no per-chunk files on disk; the disk-based success checks in Step B3 do not apply — a chunk that returns invalid JSON is the failure signal instead.
|
||||
|
||||
Subagent prompt template:
|
||||
|
||||
See `references/extraction-spec.md` for the compact subagent prompt (rules, node-ID format, confidence rubric, hyperedge and vision rules, JSON schema). Load it only here, only when at least one chunk holds a doc, paper, or image; a pure-code corpus has skipped Part B and never reads it. Pass each agent that prompt verbatim with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, and DEEP_MODE substituted, and have it return the JSON inline.
|
||||
|
||||
**Step B3 - Collect, cache, and merge**
|
||||
|
||||
Wait for all subagents. For each result:
|
||||
- Check that `graphify-out/.graphify_chunk_NN.json` exists on disk — this is the success signal
|
||||
- If the file exists and contains valid JSON with `nodes` and `edges`, include it and save to cache
|
||||
- If the file is missing, the subagent was likely dispatched as read-only (Explore type) — print a warning: "chunk N missing from disk — subagent may have been read-only. Re-run with general-purpose agent." Do not silently skip.
|
||||
- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort
|
||||
|
||||
If more than half the chunks failed or are missing, stop and tell the user to re-run and ensure `subagent_type="general-purpose"` is used.
|
||||
|
||||
Merge all chunk files into `.graphify_semantic_new.json`. **After each Agent call completes, read the real token counts from the Agent tool result's `usage` field and write them back into the chunk JSON before merging** — the chunk JSON itself always has placeholder zeros. Then run:
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json, glob
|
||||
from pathlib import Path
|
||||
|
||||
chunks = sorted(glob.glob('graphify-out/.graphify_chunk_*.json'))
|
||||
all_nodes, all_edges, all_hyperedges = [], [], []
|
||||
total_in, total_out = 0, 0
|
||||
for c in chunks:
|
||||
d = json.loads(Path(c).read_text(encoding=\"utf-8\"))
|
||||
all_nodes += d.get('nodes', [])
|
||||
all_edges += d.get('edges', [])
|
||||
all_hyperedges += d.get('hyperedges', [])
|
||||
total_in += d.get('input_tokens', 0)
|
||||
total_out += d.get('output_tokens', 0)
|
||||
Path('graphify-out/.graphify_semantic_new.json').write_text(json.dumps({
|
||||
'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges,
|
||||
'input_tokens': total_in, 'output_tokens': total_out,
|
||||
}, indent=2, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens')
|
||||
"
|
||||
```
|
||||
|
||||
Save new results to cache:
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from graphify.cache import save_semantic_cache
|
||||
from pathlib import Path
|
||||
|
||||
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
|
||||
saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', []), root='INPUT_PATH')
|
||||
print(f'Cached {saved} files')
|
||||
"
|
||||
```
|
||||
|
||||
Merge cached + new results into `graphify-out/.graphify_semantic.json`:
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
cached = json.loads(Path('graphify-out/.graphify_cached.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
|
||||
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
|
||||
|
||||
all_nodes = cached['nodes'] + new.get('nodes', [])
|
||||
all_edges = cached['edges'] + new.get('edges', [])
|
||||
all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', [])
|
||||
seen = set()
|
||||
deduped = []
|
||||
for n in all_nodes:
|
||||
if n['id'] not in seen:
|
||||
seen.add(n['id'])
|
||||
deduped.append(n)
|
||||
|
||||
merged = {
|
||||
'nodes': deduped,
|
||||
'edges': all_edges,
|
||||
'hyperedges': all_hyperedges,
|
||||
'input_tokens': new.get('input_tokens', 0),
|
||||
'output_tokens': new.get('output_tokens', 0),
|
||||
}
|
||||
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
|
||||
"
|
||||
```
|
||||
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
|
||||
|
||||
#### Part C - Merge AST + semantic into final extraction
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import sys, json
|
||||
from pathlib import Path
|
||||
|
||||
ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding=\"utf-8\"))
|
||||
sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding=\"utf-8\"))
|
||||
|
||||
# Merge: AST nodes first, semantic nodes deduplicated by id
|
||||
seen = {n['id'] for n in ast['nodes']}
|
||||
merged_nodes = list(ast['nodes'])
|
||||
for n in sem['nodes']:
|
||||
if n['id'] not in seen:
|
||||
merged_nodes.append(n)
|
||||
seen.add(n['id'])
|
||||
|
||||
merged_edges = ast['edges'] + sem['edges']
|
||||
merged_hyperedges = sem.get('hyperedges', [])
|
||||
merged = {
|
||||
'nodes': merged_nodes,
|
||||
'edges': merged_edges,
|
||||
'hyperedges': merged_hyperedges,
|
||||
'input_tokens': sem.get('input_tokens', 0),
|
||||
'output_tokens': sem.get('output_tokens', 0),
|
||||
}
|
||||
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
|
||||
total = len(merged_nodes)
|
||||
edges = len(merged_edges)
|
||||
print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(sem[\"nodes\"])} semantic)')
|
||||
"
|
||||
```
|
||||
|
||||
### Step 4 - Build graph, cluster, analyze, generate outputs
|
||||
|
||||
**Before starting:** the code blocks below pass `directed=IS_DIRECTED` to `build_from_json()`. Replace `IS_DIRECTED` with `True` if `--directed` was given (builds a `DiGraph` preserving edge direction source→target), otherwise `False` (the default undirected `Graph`). Substitute it the same way you substitute `INPUT_PATH` — do not leave the literal `IS_DIRECTED` in the code.
|
||||
|
||||
```bash
|
||||
mkdir -p graphify-out
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import sys, json
|
||||
from graphify.build import build_from_json
|
||||
from graphify.cluster import cluster, score_all
|
||||
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
|
||||
from graphify.report import generate
|
||||
from graphify.export import to_json
|
||||
from pathlib import Path
|
||||
|
||||
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
|
||||
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
|
||||
|
||||
# root= mirrors the --update runbook (#1361): relativize source_file to the same
|
||||
# base so the full build and incremental --update never drift apart on re-extract.
|
||||
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
|
||||
# Guard BEFORE any write: an empty extraction must not clobber a good graph.json /
|
||||
# GRAPH_REPORT.md / analysis sidecar. Check immediately after build (#1392).
|
||||
if G.number_of_nodes() == 0:
|
||||
print('ERROR: Graph is empty - extraction produced no nodes.')
|
||||
print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.')
|
||||
raise SystemExit(1)
|
||||
communities = cluster(G)
|
||||
cohesion = score_all(G, communities)
|
||||
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
|
||||
gods = god_nodes(G)
|
||||
surprises = surprising_connections(G, communities)
|
||||
labels = {cid: 'Community ' + str(cid) for cid in communities}
|
||||
# Placeholder questions - regenerated with real labels in Step 5
|
||||
questions = suggest_questions(G, communities, labels)
|
||||
|
||||
# Export FIRST and honor the #479 shrink-guard: to_json returns False (writing
|
||||
# nothing) when the new graph is smaller than the existing graph.json. Only write
|
||||
# GRAPH_REPORT.md + the analysis sidecar when the graph was actually written, so
|
||||
# they never describe a graph that graph.json doesn't contain (#1392).
|
||||
wrote = to_json(G, communities, 'graphify-out/graph.json')
|
||||
if not wrote:
|
||||
print('ERROR: refused to shrink graphify-out/graph.json (existing graph has more nodes; #479).')
|
||||
print('If this shrink is intentional (you deleted files), re-run a full build with --force.')
|
||||
raise SystemExit(1)
|
||||
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions)
|
||||
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
|
||||
analysis = {
|
||||
'communities': {str(k): v for k, v in communities.items()},
|
||||
'cohesion': {str(k): v for k, v in cohesion.items()},
|
||||
'gods': gods,
|
||||
'surprises': surprises,
|
||||
'questions': questions,
|
||||
}
|
||||
Path('graphify-out/.graphify_analysis.json').write_text(json.dumps(analysis, indent=2, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities')
|
||||
"
|
||||
```
|
||||
|
||||
If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization.
|
||||
|
||||
Replace INPUT_PATH with the actual path.
|
||||
|
||||
### Step 4.5 - Graph health check (read-only integrity gate)
|
||||
|
||||
A non-destructive diagnostic on the extraction, before labeling. It surfaces edge collapse, dangling/missing endpoints, and self-loops — the silent-corruption modes of incremental updates and AST/LLM id mismatches. Read-only; never aborts.
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
from graphify.diagnostics import diagnose_extraction, format_diagnostic_report
|
||||
|
||||
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
|
||||
summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH')
|
||||
print(format_diagnostic_report(summary))
|
||||
flags = [f'{summary[k]} {label}' for k, label in (
|
||||
('dangling_endpoint_edges', 'dangling-endpoint edges'),
|
||||
('missing_endpoint_edges', 'missing-endpoint edges'),
|
||||
('self_loop_edges', 'self-loop edges'),
|
||||
('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'),
|
||||
('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'),
|
||||
) if summary.get(k, 0)]
|
||||
print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).')
|
||||
"
|
||||
```
|
||||
|
||||
Substitute `IS_DIRECTED` and `INPUT_PATH` as in Step 4. If a `GRAPH HEALTH WARNING` prints, surface it in the final summary (do not abort — the graph is still usable, but the integrity issue must be visible, per the Honesty Rules).
|
||||
|
||||
### Step 5 - Label communities
|
||||
|
||||
Read `graphify-out/.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading").
|
||||
|
||||
Then regenerate the report and save the labels for the visualizer:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import sys, json
|
||||
from graphify.build import build_from_json
|
||||
from graphify.cluster import score_all
|
||||
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
|
||||
from graphify.report import generate
|
||||
from pathlib import Path
|
||||
|
||||
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
|
||||
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
|
||||
analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text(encoding=\"utf-8\"))
|
||||
|
||||
# root= as in Step 4 / the --update runbook (#1361) — same base for node-key parity.
|
||||
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
|
||||
communities = {int(k): v for k, v in analysis['communities'].items()}
|
||||
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
|
||||
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
|
||||
|
||||
# LABELS - replace these with the names you chose above
|
||||
labels = LABELS_DICT
|
||||
|
||||
# Regenerate questions with real community labels (labels affect question phrasing)
|
||||
questions = suggest_questions(G, communities, labels)
|
||||
|
||||
report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions)
|
||||
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
|
||||
Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print('Report updated with community labels')
|
||||
"
|
||||
```
|
||||
|
||||
Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`).
|
||||
Replace INPUT_PATH with the actual path.
|
||||
|
||||
### Step 6 - Generate Obsidian vault (opt-in) + HTML
|
||||
|
||||
**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was explicitly given** — skip it otherwise, it generates one file per node.
|
||||
|
||||
If `--obsidian` was given:
|
||||
|
||||
- If `--obsidian-dir <path>` was also given, pass it via `--dir`. Otherwise defaults to `graphify-out/obsidian`.
|
||||
|
||||
```bash
|
||||
graphify export obsidian
|
||||
# or with custom dir: graphify export obsidian --dir ~/vaults/my-project
|
||||
```
|
||||
|
||||
Generate the HTML graph (always, unless `--no-viz`):
|
||||
|
||||
```bash
|
||||
graphify export html # auto-aggregates to community view if graph > 5000 nodes
|
||||
# or: graphify export html --no-viz
|
||||
```
|
||||
|
||||
### Steps 6b-8 - Wiki, Neo4j, FalkorDB, SVG, GraphML, MCP, benchmark (only on their flags)
|
||||
|
||||
These run only when their flag is present (`--wiki`, `--neo4j`/`--neo4j-push`, `--falkordb`/`--falkordb-push`, `--svg`, `--graphml`, `--mcp`) or, for the token-reduction benchmark, when `total_words` exceeds 5,000. A default run with no export flags skips all of them. See `references/exports.md` for each one. Run any `--wiki` export before Step 9 cleanup so `.graphify_labels.json` is still available.
|
||||
|
||||
---
|
||||
|
||||
### Step 9 - Save manifest, update cost tracker, clean up, and report
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from graphify.detect import save_manifest
|
||||
|
||||
# Save manifest for --update
|
||||
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
|
||||
# In --update mode, 'all_files' carries the full corpus; 'files' is the changed
|
||||
# subset. Full-rebuild mode populates only 'files', so the fallback handles that.
|
||||
# root= relativizes the manifest keys to the scan root (same base as the build),
|
||||
# so the on-disk manifest is portable across clones/machines and a later --update
|
||||
# matches cached files instead of missing every one (#1417).
|
||||
save_manifest(detect.get('all_files') or detect['files'], root='INPUT_PATH')
|
||||
|
||||
# Update cumulative cost tracker
|
||||
extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
|
||||
input_tok = extract.get('input_tokens', 0)
|
||||
output_tok = extract.get('output_tokens', 0)
|
||||
|
||||
cost_path = Path('graphify-out/cost.json')
|
||||
if cost_path.exists():
|
||||
cost = json.loads(cost_path.read_text(encoding=\"utf-8\"))
|
||||
else:
|
||||
cost = {'runs': [], 'total_input_tokens': 0, 'total_output_tokens': 0}
|
||||
|
||||
cost['runs'].append({
|
||||
'date': datetime.now(timezone.utc).isoformat(),
|
||||
'input_tokens': input_tok,
|
||||
'output_tokens': output_tok,
|
||||
'files': detect.get('total_files', 0),
|
||||
})
|
||||
cost['total_input_tokens'] += input_tok
|
||||
cost['total_output_tokens'] += output_tok
|
||||
cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\")
|
||||
|
||||
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
|
||||
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
|
||||
"
|
||||
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
|
||||
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
|
||||
rm -f graphify-out/.needs_update 2>/dev/null || true
|
||||
```
|
||||
|
||||
Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root.
|
||||
|
||||
Tell the user (omit the obsidian line unless --obsidian was given):
|
||||
```
|
||||
Graph complete. Outputs in PATH_TO_DIR/graphify-out/
|
||||
|
||||
graph.html - interactive graph, open in browser
|
||||
GRAPH_REPORT.md - audit report
|
||||
graph.json - raw graph data
|
||||
obsidian/ - Obsidian vault (only if --obsidian was given)
|
||||
```
|
||||
|
||||
If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi
|
||||
|
||||
Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.
|
||||
|
||||
Then paste these sections from GRAPH_REPORT.md directly into the chat:
|
||||
- God Nodes
|
||||
- Surprising Connections
|
||||
- Suggested Questions
|
||||
|
||||
Do NOT paste the full report - just those three sections. Keep it concise.
|
||||
|
||||
Then immediately offer to explore. Pick the single most interesting suggested question from the report - the one that crosses the most community boundaries or has the most surprising bridge node - and ask:
|
||||
|
||||
> "The most interesting question this graph can answer: **[question]**. Want me to trace it?"
|
||||
|
||||
If the user says yes, run `/graphify query "[question]"` on the graph and walk them through the answer using the graph structure - which nodes connect, which community boundaries get crossed, what the path reveals. Keep going as long as they want to explore. Each answer should end with a natural follow-up ("this connects to X - want to go deeper?") so the session feels like navigation, not a one-shot report.
|
||||
|
||||
The graph is the map. Your job after the pipeline is to be the guide.
|
||||
|
||||
---
|
||||
|
||||
## Interpreter guard for subcommands
|
||||
|
||||
Before running any subcommand below (`--update`, `--cluster-only`, `query`, `path`, `explain`, `add`), check that `.graphify_python` exists. If it's missing (e.g. user deleted `graphify-out/`), re-resolve the interpreter first:
|
||||
|
||||
```bash
|
||||
if [ ! -f graphify-out/.graphify_python ]; then
|
||||
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
|
||||
if [ -n "$GRAPHIFY_BIN" ]; then
|
||||
PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
|
||||
case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac
|
||||
else
|
||||
PYTHON="python3"
|
||||
fi
|
||||
mkdir -p graphify-out
|
||||
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
|
||||
fi
|
||||
```
|
||||
|
||||
## For --update and --cluster-only
|
||||
|
||||
Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows.
|
||||
|
||||
---
|
||||
|
||||
## For /graphify query
|
||||
|
||||
When `graphify-out/graph.json` already exists and the user asks a question about the corpus, answer from the graph rather than rebuilding it:
|
||||
|
||||
```bash
|
||||
graphify query "<question>"
|
||||
```
|
||||
|
||||
Before traversal, expand the question against the graph's own vocabulary so a wording mismatch does not collapse the answer to noise. If the `graphify query` CLI is unavailable, fall back to an inline NetworkX traversal of `graphify-out/graph.json`. Answer using only what the graph output contains, and quote `source_location` when citing a specific fact. For that vocab-expansion step, the BFS/DFS traversal modes, the `--budget` cap, the NetworkX fallback, `save-result` feedback, and the `/graphify path` and `/graphify explain` flows, see `references/query.md`.
|
||||
|
||||
---
|
||||
|
||||
## For /graphify add and --watch
|
||||
|
||||
Neither is part of the default build. When the user runs `/graphify add <url>` to fetch a URL into the corpus, or passes `--watch` to auto-rebuild on file changes, see `references/add-watch.md`.
|
||||
|
||||
---
|
||||
|
||||
## For the commit hook and native CLAUDE.md integration
|
||||
|
||||
When the user asks to install the post-commit auto-rebuild hook or wire graphify into a project's CLAUDE.md, see `references/hooks.md`.
|
||||
|
||||
---
|
||||
|
||||
## Honesty Rules
|
||||
|
||||
- Never invent an edge. If unsure, use AMBIGUOUS.
|
||||
- Never skip the corpus check warning.
|
||||
- Always show token cost in the report.
|
||||
- Never hide cohesion scores behind symbols - show the raw number.
|
||||
- Never run HTML viz on a graph with more than 5,000 nodes without warning the user.
|
||||
56
.agents/skills/graphify/references/add-watch.md
Normal file
56
.agents/skills/graphify/references/add-watch.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# graphify reference: add a URL and watch a folder
|
||||
|
||||
Load this when the user ran `/graphify add <url>` or passed `--watch`. Neither is part of the default build.
|
||||
|
||||
## For /graphify add
|
||||
|
||||
Fetch a URL and add it to the corpus, then update the graph.
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import sys
|
||||
from graphify.ingest import ingest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
out = ingest('URL', Path('./raw'), author='AUTHOR', contributor='CONTRIBUTOR')
|
||||
print(f'Saved to {out}')
|
||||
except ValueError as e:
|
||||
print(f'error: {e}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except RuntimeError as e:
|
||||
print(f'error: {e}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
"
|
||||
```
|
||||
|
||||
Replace `URL` with the actual URL, `AUTHOR` with the user's name if provided, `CONTRIBUTOR` likewise. If the command exits with an error, tell the user what went wrong - do not silently continue. After a successful save, automatically run the `--update` pipeline on `./raw` to merge the new file into the existing graph.
|
||||
|
||||
Supported URL types (auto-detected):
|
||||
- YouTube / any video URL → audio downloaded via yt-dlp, transcribed to `.txt` on next run (requires `pip install 'graphifyy[video]'`)
|
||||
- Twitter/X → fetched via oEmbed, saved as `.md` with tweet text and author
|
||||
- arXiv → abstract + metadata saved as `.md`
|
||||
- PDF → downloaded as `.pdf`
|
||||
- Images (.png/.jpg/.webp) → downloaded, Claude vision extracts on next run
|
||||
- Any webpage → converted to markdown via html2text
|
||||
|
||||
---
|
||||
|
||||
## For --watch
|
||||
|
||||
Start a background watcher that monitors a folder and auto-updates the graph when files change.
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -m graphify.watch INPUT_PATH --debounce 3
|
||||
```
|
||||
|
||||
Replace INPUT_PATH with the folder to watch. Behavior depends on what changed:
|
||||
|
||||
- **Code files only (.py, .ts, .go, etc.):** re-runs AST extraction + rebuild + cluster immediately, no LLM needed. `graph.json` and `GRAPH_REPORT.md` are updated automatically.
|
||||
- **Docs, papers, or images:** writes a `graphify-out/needs_update` flag and prints a notification to run `/graphify --update` (LLM semantic re-extraction required).
|
||||
|
||||
Debounce (default 3s): waits until file activity stops before triggering, so a wave of parallel agent writes doesn't trigger a rebuild per file.
|
||||
|
||||
Press Ctrl+C to stop.
|
||||
|
||||
For agentic workflows: run `--watch` in a background terminal. Code changes from agent waves are picked up automatically between waves. If agents are also writing docs or notes, you'll need a manual `/graphify --update` after those waves.
|
||||
87
.agents/skills/graphify/references/exports.md
Normal file
87
.agents/skills/graphify/references/exports.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# graphify reference: extra exports and benchmark
|
||||
|
||||
Load this when the user passed one of the export flags (`--wiki`, `--neo4j`, `--neo4j-push`, `--falkordb`, `--falkordb-push`, `--svg`, `--graphml`, `--mcp`), or when the corpus is large enough for the token-reduction benchmark. Each step runs only for its own flag.
|
||||
|
||||
### Step 6b - Wiki (only if --wiki flag)
|
||||
|
||||
**Only run this step if `--wiki` was explicitly given in the original command.**
|
||||
|
||||
Run this before Step 9 (cleanup) so `.graphify_labels.json` is still available.
|
||||
|
||||
```bash
|
||||
graphify export wiki
|
||||
```
|
||||
|
||||
### Step 7 - Neo4j export (only if --neo4j or --neo4j-push flag)
|
||||
|
||||
**If `--neo4j`** - generate a Cypher file for manual import:
|
||||
|
||||
```bash
|
||||
graphify export neo4j
|
||||
```
|
||||
|
||||
**If `--neo4j-push <uri>`** - push directly to a running Neo4j instance. Ask the user for credentials if not provided:
|
||||
|
||||
```bash
|
||||
graphify export neo4j --push bolt://localhost:7687 --user neo4j --password PASSWORD
|
||||
```
|
||||
|
||||
Default URI is `bolt://localhost:7687`, default user is `neo4j`. Uses MERGE - safe to re-run without creating duplicates.
|
||||
|
||||
### Step 7a - FalkorDB export (only if --falkordb or --falkordb-push flag)
|
||||
|
||||
**If `--falkordb`** - generate a Cypher file. The statements are OpenCypher, but FalkorDB's `GRAPH.QUERY` runs one statement at a time (no bulk script import like Neo4j's `cypher-shell`), so prefer `--falkordb-push` to load a graph. Use this only when you want the portable `cypher.txt` artifact:
|
||||
|
||||
```bash
|
||||
graphify export falkordb
|
||||
```
|
||||
|
||||
**If `--falkordb-push <uri>`** - push directly to a running FalkorDB instance. Credentials are optional; ask the user only if the instance requires auth:
|
||||
|
||||
```bash
|
||||
graphify export falkordb --push falkordb://localhost:6379
|
||||
```
|
||||
|
||||
Default URI is `falkordb://localhost:6379` (the scheme is informational - `redis://` or a bare `host:port` work too), auth is optional, and the target graph defaults to `graphify`. Uses MERGE - safe to re-run without creating duplicates.
|
||||
|
||||
### Step 7b - SVG export (only if --svg flag)
|
||||
|
||||
```bash
|
||||
graphify export svg
|
||||
```
|
||||
|
||||
### Step 7c - GraphML export (only if --graphml flag)
|
||||
|
||||
```bash
|
||||
graphify export graphml
|
||||
```
|
||||
|
||||
### Step 7d - MCP server (only if --mcp flag)
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -m graphify.serve graphify-out/graph.json
|
||||
```
|
||||
|
||||
This starts a stdio MCP server that exposes tools: `query_graph`, `get_node`, `get_neighbors`, `get_community`, `god_nodes`, `graph_stats`, `shortest_path`. Add to Claude Desktop or any MCP-compatible agent orchestrator so other agents can query the graph live.
|
||||
|
||||
To configure in Claude Desktop, add to `claude_desktop_config.json`. Claude Desktop can't run `$(...)`, and under `uv tool install` the system `python3` can't import graphify — so set `command` to the **absolute interpreter path** printed by `cat graphify-out/.graphify_python`:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"graphify": {
|
||||
"command": "<absolute path from: cat graphify-out/.graphify_python>",
|
||||
"args": ["-m", "graphify.serve", "/absolute/path/to/graphify-out/graph.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 8 - Token reduction benchmark (only if total_words > 5000)
|
||||
|
||||
If `total_words` from `graphify-out/.graphify_detect.json` is greater than 5,000, run:
|
||||
|
||||
```bash
|
||||
graphify benchmark
|
||||
```
|
||||
|
||||
Print the output directly in chat. If `total_words <= 5000`, skip silently - the graph value is structural clarity, not token compression, for small corpora.
|
||||
31
.agents/skills/graphify/references/extraction-spec.md
Normal file
31
.agents/skills/graphify/references/extraction-spec.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# graphify reference: extraction subagent prompt (compact)
|
||||
|
||||
Load this in Step 3 Part B when the corpus has at least one doc, paper, or image chunk. A pure-code corpus skips Part B and never reads this file. Each semantic subagent receives the prompt below verbatim (substitute FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, and DEEP_MODE).
|
||||
|
||||
```
|
||||
You are a graphify extraction subagent. Read the files listed and extract a knowledge graph fragment.
|
||||
Output ONLY valid JSON matching the schema below - no explanation, no markdown fences, no preamble.
|
||||
|
||||
Files (chunk CHUNK_NUM of TOTAL_CHUNKS):
|
||||
FILE_LIST
|
||||
|
||||
Rules:
|
||||
- EXTRACTED: relationship explicit in source (import, call, citation)
|
||||
- INFERRED: reasonable inference (shared structure, implied dependency)
|
||||
- AMBIGUOUS: uncertain — flag it, do not omit
|
||||
- Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language.
|
||||
- Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
|
||||
- Image files: use vision — understand what the image IS, not just OCR
|
||||
- DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting.
|
||||
- Semantic similarity: if two concepts solve the same problem or represent the same idea without a structural link (no import, call, or citation), add a `semantically_similar_to` edge marked INFERRED with confidence_score 0.6-0.95. Non-obvious cross-file links only.
|
||||
- Hyperedges: if 3+ nodes share a concept, flow, or pattern not captured by pairwise edges, add a hyperedge to a top-level `hyperedges` array. Use sparingly. Max 3 per chunk.
|
||||
- If a file has YAML frontmatter (--- ... ---), copy source_url, captured_at, author, contributor onto every node from that file.
|
||||
- confidence_score is REQUIRED on every edge — never omit it, never use 0.5 as a default. EXTRACTED = 1.0 always. INFERRED: pick exactly ONE of 0.95 (direct structural evidence), 0.85 (strong inference), 0.75 (reasonable inference), 0.65 (weak inference), 0.55 (speculative but plausible) — never 0.5; if none fit, mark the edge AMBIGUOUS. AMBIGUOUS = 0.1-0.3.
|
||||
|
||||
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone.
|
||||
|
||||
Output exactly this JSON (no other text):
|
||||
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"<FILE_LIST path verbatim>","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"<FILE_LIST path verbatim>","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":"<FILE_LIST path verbatim>"}],"input_tokens":0,"output_tokens":0}
|
||||
|
||||
source_file RULE: set source_file to the FILE_LIST path for that file VERBATIM (absolute, no shortening to basename, no re-relativizing, no separator change). Keeps full build and --update on one base so build_merge's replace matches instead of duplicating.
|
||||
```
|
||||
46
.agents/skills/graphify/references/github-and-merge.md
Normal file
46
.agents/skills/graphify/references/github-and-merge.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# graphify reference: GitHub clone and cross-repo merge
|
||||
|
||||
Load this when the user passed one or more `https://github.com/...` URLs, or named several local subfolders to merge into one graph.
|
||||
|
||||
### Step 0 - Clone GitHub repo(s) (only if a GitHub URL was given)
|
||||
|
||||
**Single repo:**
|
||||
```bash
|
||||
LOCAL_PATH=$(graphify clone <github-url> [--branch <branch>])
|
||||
# Use LOCAL_PATH as the target for all subsequent steps
|
||||
```
|
||||
|
||||
**Multiple repos (cross-repo graph):**
|
||||
```bash
|
||||
# Clone each repo, run the full pipeline on each, then merge
|
||||
graphify clone <url1> # → ~/.graphify/repos/<owner1>/<repo1>
|
||||
graphify clone <url2> # → ~/.graphify/repos/<owner2>/<repo2>
|
||||
# Run /graphify on each local path to produce their graph.json files
|
||||
# Then merge:
|
||||
graphify merge-graphs \
|
||||
~/.graphify/repos/<owner1>/<repo1>/graphify-out/graph.json \
|
||||
~/.graphify/repos/<owner2>/<repo2>/graphify-out/graph.json \
|
||||
--out graphify-out/cross-repo-graph.json
|
||||
```
|
||||
|
||||
Graphify clones into `~/.graphify/repos/<owner>/<repo>` and reuses existing clones on repeat runs. Each node in the merged graph carries a `repo` attribute so you can filter by origin.
|
||||
|
||||
**Multiple local subfolders (monorepo or multi-service layout):**
|
||||
|
||||
The skill pipeline writes all intermediate and final outputs to `graphify-out/` in the current working directory. Running the skill on each subfolder separately will clobber the same output dir. Instead, use the CLI directly for each subfolder — it places `graphify-out/` *inside* the scanned path:
|
||||
|
||||
```bash
|
||||
graphify extract ./core/ # → ./core/graphify-out/graph.json
|
||||
graphify extract ./service/ # → ./service/graphify-out/graph.json
|
||||
graphify extract ./platform/ # → ./platform/graphify-out/graph.json
|
||||
# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set
|
||||
|
||||
# Then merge at the project root:
|
||||
graphify merge-graphs \
|
||||
./core/graphify-out/graph.json \
|
||||
./service/graphify-out/graph.json \
|
||||
./platform/graphify-out/graph.json \
|
||||
--out graphify-out/graph.json
|
||||
```
|
||||
|
||||
Once `graphify-out/graph.json` exists, the fast path above takes over: any codebase question runs `graphify query` directly on the merged graph — no re-extraction, no size gate.
|
||||
33
.agents/skills/graphify/references/hooks.md
Normal file
33
.agents/skills/graphify/references/hooks.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# graphify reference: commit hook and native CLAUDE.md integration
|
||||
|
||||
Load this when the user asked to install the post-commit hook or wire graphify into a project's CLAUDE.md.
|
||||
|
||||
## For git commit hook
|
||||
|
||||
Install a post-commit hook that auto-rebuilds the graph after every commit. No background process needed - triggers once per commit, works with any editor.
|
||||
|
||||
```bash
|
||||
graphify hook install # install
|
||||
graphify hook uninstall # remove
|
||||
graphify hook status # check
|
||||
```
|
||||
|
||||
After every `git commit`, the hook detects which code files changed (via `git diff HEAD~1`), re-runs AST extraction on those files, and rebuilds `graph.json` and `GRAPH_REPORT.md`. Doc/image changes are ignored by the hook - run `/graphify --update` manually for those.
|
||||
|
||||
If a post-commit hook already exists, graphify appends to it rather than replacing it.
|
||||
|
||||
---
|
||||
|
||||
## For native CLAUDE.md integration
|
||||
|
||||
Run once per project to make graphify always-on in Claude Code sessions:
|
||||
|
||||
```bash
|
||||
graphify claude install
|
||||
```
|
||||
|
||||
This writes a `## graphify` section to the local `CLAUDE.md` that instructs Claude to check the graph before answering codebase questions and rebuild it after code changes. No manual `/graphify` needed in future sessions.
|
||||
|
||||
```bash
|
||||
graphify claude uninstall # remove the section
|
||||
```
|
||||
311
.agents/skills/graphify/references/query.md
Normal file
311
.agents/skills/graphify/references/query.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# graphify reference: query, path, explain
|
||||
|
||||
Load this when the user asks a question against an existing graph, or runs `/graphify path` or `/graphify explain`. The core's query stub points here for the full traversal flow. These flows use the `graphify query` CLI when it is available and fall back to an inline NetworkX traversal otherwise.
|
||||
|
||||
Two traversal modes - choose based on the question:
|
||||
|
||||
| Mode | Flag | Best for |
|
||||
|------|------|----------|
|
||||
| BFS (default) | _(none)_ | "What is X connected to?" - broad context, nearest neighbors first |
|
||||
| DFS | `--dfs` | "How does X reach Y?" - trace a specific chain or dependency path |
|
||||
|
||||
First check the graph exists:
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
from pathlib import Path
|
||||
if not Path('graphify-out/graph.json').exists():
|
||||
print('ERROR: No graph found. Run /graphify <path> first to build the graph.')
|
||||
raise SystemExit(1)
|
||||
"
|
||||
```
|
||||
If it fails, stop and tell the user to run `/graphify <path>` first.
|
||||
|
||||
### Step 0 — Constrained query expansion (REQUIRED before traversal)
|
||||
|
||||
graphify's `query` CLI matches nodes via case-folded substring + IDF — there is **no stemming, no synonyms, no cross-language match** inside the binary, and the inline fallback below matches the same way. If the user's question uses different language or different domain vocabulary than the graph's labels (user says "обработчик" / graph says "handler"; user says "authentication" / graph says "Guardian"), the literal matcher returns 0 hits and the answer collapses to noise.
|
||||
|
||||
Fix this **without inventing tokens** by expanding the query against the actual graph vocabulary first:
|
||||
|
||||
1. Extract the token vocabulary from node labels:
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json, re
|
||||
from pathlib import Path
|
||||
data = json.loads(Path('graphify-out/graph.json').read_text())
|
||||
vocab = set()
|
||||
for n in data['nodes']:
|
||||
for c in re.findall(r'[^\W\d_]+', n.get('label','') or '', re.UNICODE):
|
||||
parts = re.findall(r'[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+', c) or [c]
|
||||
for p in parts:
|
||||
t = p.lower()
|
||||
if 3 <= len(t) <= 30:
|
||||
vocab.add(t)
|
||||
Path('graphify-out/.vocab.txt').write_text('\n'.join(sorted(vocab)))
|
||||
print(f'vocab: {len(vocab)} tokens')
|
||||
"
|
||||
```
|
||||
|
||||
2. Read `graphify-out/.vocab.txt`. Then for the user's question, select **up to 12 tokens from this exact list** that semantically match the query intent. Hard constraints:
|
||||
- You MUST pick only tokens present in the vocabulary file. Do NOT invent tokens.
|
||||
- If a query concept has no plausible token in the vocab, skip it — do not substitute a near-synonym from training memory.
|
||||
- If **no** vocab tokens match the query at all, output an empty list and tell the user the corpus has no relevant vocabulary for this question. Do not fabricate a search.
|
||||
- Translate cross-language: Russian "аутентификация" → look for `auth`, `credential`, `token`, `security` IFF present in vocab.
|
||||
- Morphology: "handlers" maps to `handler` IFF present; "todos" maps to `todo` IFF present.
|
||||
|
||||
3. Print the selection explicitly to the user before running the query, so the expansion is auditable:
|
||||
```
|
||||
Query expanded to (from graph vocab, N tokens): [token1, token2, ...]
|
||||
```
|
||||
If the list is empty, say so plainly and stop — do not proceed to traversal.
|
||||
|
||||
### Step 1 — Traversal
|
||||
|
||||
Build the **expanded query string** by joining the selected tokens with spaces. Use this string as `QUESTION` below — NOT the original user question. (The original question is preserved only for `save-result` at the end.)
|
||||
|
||||
Prefer the CLI when it is installed:
|
||||
```bash
|
||||
graphify query "QUESTION"
|
||||
# or: graphify query "QUESTION" --dfs --budget 3000
|
||||
```
|
||||
|
||||
If the CLI is unavailable, load `graphify-out/graph.json` and run the traversal inline:
|
||||
|
||||
1. Find the 1-3 nodes whose label best matches the expanded tokens.
|
||||
2. Run the appropriate traversal from each starting node.
|
||||
3. Read the subgraph - node labels, edge relations, confidence tags, source locations.
|
||||
4. Answer using **only** what the graph contains. Quote `source_location` when citing a specific fact.
|
||||
5. If the graph lacks enough information, say so - do not hallucinate edges.
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import sys, json
|
||||
from networkx.readwrite import json_graph
|
||||
import networkx as nx
|
||||
from pathlib import Path
|
||||
|
||||
data = json.loads(Path('graphify-out/graph.json').read_text())
|
||||
G = json_graph.node_link_graph(data, edges='links')
|
||||
|
||||
question = 'QUESTION'
|
||||
mode = 'MODE' # 'bfs' or 'dfs'
|
||||
terms = [t.lower() for t in question.split() if len(t) >= 3] # match the vocab threshold; keeps api/jwt/ios (#1392)
|
||||
|
||||
# Find best-matching start nodes
|
||||
scored = []
|
||||
for nid, ndata in G.nodes(data=True):
|
||||
label = ndata.get('label', '').lower()
|
||||
score = sum(1 for t in terms if t in label)
|
||||
if score > 0:
|
||||
scored.append((score, nid))
|
||||
scored.sort(reverse=True)
|
||||
start_nodes = [nid for _, nid in scored[:3]]
|
||||
|
||||
if not start_nodes:
|
||||
print('No matching nodes found for query terms:', terms)
|
||||
sys.exit(0)
|
||||
|
||||
subgraph_nodes = set()
|
||||
subgraph_edges = []
|
||||
|
||||
if mode == 'dfs':
|
||||
# DFS: follow one path as deep as possible before backtracking.
|
||||
# Depth-limited to 6 to avoid traversing the whole graph.
|
||||
visited = set()
|
||||
stack = [(n, 0) for n in reversed(start_nodes)]
|
||||
while stack:
|
||||
node, depth = stack.pop()
|
||||
if node in visited or depth > 6:
|
||||
continue
|
||||
visited.add(node)
|
||||
subgraph_nodes.add(node)
|
||||
for neighbor in G.neighbors(node):
|
||||
if neighbor not in visited:
|
||||
stack.append((neighbor, depth + 1))
|
||||
subgraph_edges.append((node, neighbor))
|
||||
else:
|
||||
# BFS: explore all neighbors layer by layer up to depth 3.
|
||||
frontier = set(start_nodes)
|
||||
subgraph_nodes = set(start_nodes)
|
||||
for _ in range(3):
|
||||
next_frontier = set()
|
||||
for n in frontier:
|
||||
for neighbor in G.neighbors(n):
|
||||
if neighbor not in subgraph_nodes:
|
||||
next_frontier.add(neighbor)
|
||||
subgraph_edges.append((n, neighbor))
|
||||
subgraph_nodes.update(next_frontier)
|
||||
frontier = next_frontier
|
||||
|
||||
# Token-budget aware output: rank by relevance, cut at budget (~4 chars/token)
|
||||
token_budget = BUDGET # default 2000
|
||||
char_budget = token_budget * 4
|
||||
|
||||
# Score each node by term overlap for ranked output
|
||||
def relevance(nid):
|
||||
label = G.nodes[nid].get('label', '').lower()
|
||||
return sum(1 for t in terms if t in label)
|
||||
|
||||
ranked_nodes = sorted(subgraph_nodes, key=relevance, reverse=True)
|
||||
|
||||
lines = [f'Traversal: {mode.upper()} | Start: {[G.nodes[n].get(\"label\",n) for n in start_nodes]} | {len(subgraph_nodes)} nodes']
|
||||
for nid in ranked_nodes:
|
||||
d = G.nodes[nid]
|
||||
lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]')
|
||||
for u, v in subgraph_edges:
|
||||
if u in subgraph_nodes and v in subgraph_nodes:
|
||||
_raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}')
|
||||
|
||||
output = '\n'.join(lines)
|
||||
if len(output) > char_budget:
|
||||
output = output[:char_budget] + f'\n... (truncated at ~{token_budget} token budget - use --budget N for more)'
|
||||
print(output)
|
||||
"
|
||||
```
|
||||
|
||||
Replace `QUESTION` with the **expanded** query string, `MODE` with `bfs` or `dfs`, and `BUDGET` with the token budget (default `2000`, or whatever `--budget N` specifies). Then answer based on the subgraph output above, using only what the graph contains.
|
||||
|
||||
After writing the answer, save it back into the graph so it improves future queries. Include the expanded tokens inside the `--answer` text (e.g. `"Expanded from original query via vocab: [tokens]. Then traversed..."`) so the next `--update` extracts the expansion history as a graph node:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -m graphify save-result --question "ORIGINAL_QUESTION" --answer "ANSWER" --type query --nodes NODE1 NODE2
|
||||
```
|
||||
|
||||
Replace `ORIGINAL_QUESTION` with the user's verbatim question, `ANSWER` with your full answer text (containing the expanded-token trace), `NODE1 NODE2` with the list of node labels you cited. This closes the feedback loop: the next `--update` will extract this Q&A as a node in the graph.
|
||||
|
||||
**Work memory (self-improving loop).** Add an `--outcome` so future sessions learn from this one — append `--outcome useful|dead_end|corrected` to the `save-result` command (and `--correction "the right answer"` when correcting):
|
||||
|
||||
- `useful` — the cited nodes answered the question well (they become *preferred sources*).
|
||||
- `dead_end` — the question/path led nowhere; don't re-derive it next time.
|
||||
- `corrected` — the saved answer was wrong; `--correction` records what was right.
|
||||
|
||||
At the **start** of graph work, refresh and read the lessons: run `graphify reflect --if-stale` (cheap, deterministic, no LLM; `--if-stale` makes it a no-op when `LESSONS.md` is already newer than every input, e.g. when the git hook just refreshed it), then read `graphify-out/reflections/LESSONS.md`. It lists **preferred sources** (start there), **known dead ends** (skip them), and prior **corrections**. Running `reflect` yourself keeps the lessons current even without the git hook installed; if the post-commit hook *is* installed, `--if-stale` means your session-start run costs almost nothing.
|
||||
|
||||
---
|
||||
|
||||
## For /graphify path
|
||||
|
||||
Find the shortest path between two named concepts in the graph. Prefer the CLI when installed:
|
||||
|
||||
```bash
|
||||
graphify path "NODE_A" "NODE_B"
|
||||
```
|
||||
|
||||
If the CLI is unavailable, run it inline:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json, sys
|
||||
import networkx as nx
|
||||
from networkx.readwrite import json_graph
|
||||
from pathlib import Path
|
||||
|
||||
data = json.loads(Path('graphify-out/graph.json').read_text())
|
||||
G = json_graph.node_link_graph(data, edges='links')
|
||||
|
||||
a_term = 'NODE_A'
|
||||
b_term = 'NODE_B'
|
||||
|
||||
def find_node(term):
|
||||
term = term.lower()
|
||||
scored = sorted(
|
||||
[(sum(1 for w in term.split() if w in G.nodes[n].get('label','').lower()), n)
|
||||
for n in G.nodes()],
|
||||
reverse=True
|
||||
)
|
||||
return scored[0][1] if scored and scored[0][0] > 0 else None
|
||||
|
||||
src = find_node(a_term)
|
||||
tgt = find_node(b_term)
|
||||
|
||||
if not src or not tgt:
|
||||
print(f'Could not find nodes matching: {a_term!r} or {b_term!r}')
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
path = nx.shortest_path(G, src, tgt)
|
||||
print(f'Shortest path ({len(path)-1} hops):')
|
||||
for i, nid in enumerate(path):
|
||||
label = G.nodes[nid].get('label', nid)
|
||||
if i < len(path) - 1:
|
||||
_raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
print(f' {label} --{rel}--> [{conf}]')
|
||||
else:
|
||||
print(f' {label}')
|
||||
except nx.NetworkXNoPath:
|
||||
print(f'No path found between {a_term!r} and {b_term!r}')
|
||||
except nx.NodeNotFound as e:
|
||||
print(f'Node not found: {e}')
|
||||
"
|
||||
```
|
||||
|
||||
Replace `NODE_A` and `NODE_B` with the actual concept names from the user. Then explain the path in plain language - what each hop means, why it's significant.
|
||||
|
||||
After writing the explanation, save it back:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -m graphify save-result --question "Path from NODE_A to NODE_B" --answer "ANSWER" --type path_query --nodes NODE_A NODE_B
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## For /graphify explain
|
||||
|
||||
Give a plain-language explanation of a single node - everything connected to it. Prefer the CLI when installed:
|
||||
|
||||
```bash
|
||||
graphify explain "NODE_NAME"
|
||||
```
|
||||
|
||||
If the CLI is unavailable, run it inline:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json, sys
|
||||
import networkx as nx
|
||||
from networkx.readwrite import json_graph
|
||||
from pathlib import Path
|
||||
|
||||
data = json.loads(Path('graphify-out/graph.json').read_text())
|
||||
G = json_graph.node_link_graph(data, edges='links')
|
||||
|
||||
term = 'NODE_NAME'
|
||||
term_lower = term.lower()
|
||||
|
||||
# Find best matching node
|
||||
scored = sorted(
|
||||
[(sum(1 for w in term_lower.split() if w in G.nodes[n].get('label','').lower()), n)
|
||||
for n in G.nodes()],
|
||||
reverse=True
|
||||
)
|
||||
if not scored or scored[0][0] == 0:
|
||||
print(f'No node matching {term!r}')
|
||||
sys.exit(0)
|
||||
|
||||
nid = scored[0][1]
|
||||
data_n = G.nodes[nid]
|
||||
print(f'NODE: {data_n.get(\"label\", nid)}')
|
||||
print(f' source: {data_n.get(\"source_file\",\"unknown\")}')
|
||||
print(f' type: {data_n.get(\"file_type\",\"unknown\")}')
|
||||
print(f' degree: {G.degree(nid)}')
|
||||
print()
|
||||
print('CONNECTIONS:')
|
||||
for neighbor in G.neighbors(nid):
|
||||
_raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
nlabel = G.nodes[neighbor].get('label', neighbor)
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
src_file = G.nodes[neighbor].get('source_file', '')
|
||||
print(f' --{rel}--> {nlabel} [{conf}] ({src_file})')
|
||||
"
|
||||
```
|
||||
|
||||
Replace `NODE_NAME` with the concept the user asked about. Then write a 3-5 sentence explanation of what this node is, what it connects to, and why those connections are significant. Use the source locations as citations.
|
||||
|
||||
After writing the explanation, save it back:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -m graphify save-result --question "Explain NODE_NAME" --answer "ANSWER" --type explain --nodes NODE_NAME
|
||||
```
|
||||
52
.agents/skills/graphify/references/transcribe.md
Normal file
52
.agents/skills/graphify/references/transcribe.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# graphify reference: transcribe video and audio
|
||||
|
||||
Load this only when `detect` reported one or more `video` files. A corpus with no video never reads this.
|
||||
|
||||
### Step 2.5 - Transcribe video / audio files (only if video files detected)
|
||||
|
||||
Skip this step entirely if `detect` returned zero `video` files.
|
||||
|
||||
Video and audio files cannot be read directly. Transcribe them to text first, then treat the transcripts as doc files in Step 3.
|
||||
|
||||
**Strategy:** Read the god nodes from `graphify-out/.graphify_detect.json` (or the analysis file if it exists from a previous run). You are already a language model — write a one-sentence domain hint yourself from those labels. Then pass it to Whisper as the initial prompt. No separate API call needed.
|
||||
|
||||
**However**, if the corpus has *only* video files and no other docs/code, use the generic fallback prompt: `"Use proper punctuation and paragraph breaks."`
|
||||
|
||||
**Step 1 - Write the Whisper prompt yourself.**
|
||||
|
||||
Read the top god node labels from detect output or analysis, then compose a short domain hint sentence, for example:
|
||||
|
||||
- Labels: `transformer, attention, encoder, decoder` → `"Machine learning research on transformer architectures and attention mechanisms. Use proper punctuation and paragraph breaks."`
|
||||
- Labels: `kubernetes, deployment, pod, helm` → `"DevOps discussion about Kubernetes deployments and Helm charts. Use proper punctuation and paragraph breaks."`
|
||||
|
||||
**Export** it as `GRAPHIFY_WHISPER_PROMPT` (the exact name the transcriber reads — and it must be `export`ed so the child Python process sees it) for the next command.
|
||||
|
||||
**Step 2 - Transcribe:**
|
||||
|
||||
```bash
|
||||
export GRAPHIFY_WHISPER_MODEL=base # or whatever --whisper-model the user passed (must be exported)
|
||||
export GRAPHIFY_WHISPER_PROMPT="<the one-sentence domain hint you composed in Step 1>"
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json, os, sys
|
||||
from pathlib import Path
|
||||
from graphify.transcribe import transcribe_all
|
||||
|
||||
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
|
||||
video_files = detect.get('files', {}).get('video', [])
|
||||
prompt = os.environ.get('GRAPHIFY_WHISPER_PROMPT', 'Use proper punctuation and paragraph breaks.')
|
||||
|
||||
transcript_paths = transcribe_all(video_files, initial_prompt=prompt)
|
||||
# Write the JSON from Python (NOT a shell '>' redirect): transcribe_all/Whisper
|
||||
# print progress to stdout, which would otherwise corrupt the JSON file (#1392).
|
||||
Path('graphify-out/.graphify_transcripts.json').write_text(json.dumps(transcript_paths, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print(f'Transcribed {len(transcript_paths)} file(s)', file=sys.stderr)
|
||||
"
|
||||
```
|
||||
|
||||
After transcription:
|
||||
- Read the transcript paths from `graphify-out/.graphify_transcripts.json`
|
||||
- Add them to the docs list before dispatching semantic subagents in Step 3B
|
||||
- Print how many transcripts were created: `Transcribed N video file(s) -> treating as docs`
|
||||
- If transcription fails for a file, print a warning and continue with the rest
|
||||
|
||||
**Whisper model:** Default is `base`. If the user passed `--whisper-model <name>`, `export GRAPHIFY_WHISPER_MODEL=<name>` (it must be exported, not just assigned) before running the command above.
|
||||
192
.agents/skills/graphify/references/update.md
Normal file
192
.agents/skills/graphify/references/update.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# graphify reference: incremental update and cluster-only
|
||||
|
||||
Load this only when the user passed `--update` or `--cluster-only`. A first-time full build never reads this file.
|
||||
|
||||
## For --update (incremental re-extraction)
|
||||
|
||||
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import sys, json
|
||||
from graphify.detect import detect_incremental, save_manifest
|
||||
from pathlib import Path
|
||||
|
||||
result = detect_incremental(Path('INPUT_PATH'))
|
||||
new_total = result.get('new_total', 0)
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
Path('graphify-out/.graphify_incremental.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\")
|
||||
deleted = list(result.get('deleted_files', []))
|
||||
if new_total == 0 and not deleted:
|
||||
print('No files changed since last run. Nothing to update.')
|
||||
raise SystemExit(0)
|
||||
if deleted:
|
||||
print(f'{len(deleted)} deleted file(s) to prune.')
|
||||
if new_total > 0:
|
||||
print(f'{new_total} new/changed file(s) to re-extract.')
|
||||
"
|
||||
```
|
||||
|
||||
Then populate `.graphify_detect.json` so Steps 3A–6 (which read it unconditionally) see the right state for an incremental run. `files` carries the changed subset (drives Step 3A AST + Step 3B0 cache check on only what changed); `all_files` carries the full corpus for any step that needs corpus-wide context:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
r = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
|
||||
Path('graphify-out/.graphify_detect.json').write_text(json.dumps({
|
||||
'files': r.get('new_files', {}),
|
||||
'all_files': r.get('files', {}),
|
||||
'total_files': r.get('new_total', 0),
|
||||
'total_words': r.get('total_words', 0),
|
||||
'skipped_sensitive': r.get('skipped_sensitive', []),
|
||||
'needs_graph': True,
|
||||
}, ensure_ascii=False), encoding=\"utf-8\")
|
||||
"
|
||||
```
|
||||
|
||||
If new files exist, first check whether all changed files are code files:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {}
|
||||
code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'}
|
||||
new_files = result.get('new_files', {})
|
||||
all_changed = [f for files in new_files.values() for f in files]
|
||||
code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed)
|
||||
print('code_only:', code_only)
|
||||
"
|
||||
```
|
||||
|
||||
If `code_only` is True: print `[graphify update] Code-only changes detected - skipping semantic extraction (no LLM needed)`, run only Step 3A (AST) on the changed files, skip Step 3B entirely (no subagents), then go straight to merge and Steps 4–8.
|
||||
|
||||
If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal.
|
||||
|
||||
|
||||
If no new files exist (only deletions), create an empty extraction so the merge step can prune:
|
||||
|
||||
```bash
|
||||
if [ ! -f graphify-out/.graphify_extract.json ]; then
|
||||
echo '[graphify update] Only deletions -- creating empty extraction for merge.'
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
Path('graphify-out/.graphify_extract.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
|
||||
"
|
||||
fi
|
||||
```
|
||||
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
from graphify.build import build_merge
|
||||
from graphify.detect import save_manifest
|
||||
|
||||
# Load new extraction and incremental state
|
||||
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
|
||||
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
|
||||
deleted = list(incremental.get('deleted_files', []))
|
||||
# prune_sources is ONLY for genuinely DELETED files. Changed/re-extracted files are
|
||||
# handled by build_merge's replace-on-re-extract (#1344): every source_file in
|
||||
# new_chunks is dropped from the base before merge, so old/stale nodes don't survive.
|
||||
# Do NOT add `changed` here: with root= passed, prune_set relativizes to the same base
|
||||
# as the freshly merged nodes and would DELETE the re-extracted content (#1178 is moot
|
||||
# now that replace — not the dedup pass — reconciles changed files).
|
||||
prune = list(deleted) or None
|
||||
|
||||
# Use build_merge() — reads graph.json directly without NetworkX round-trip
|
||||
# so edge direction (calls, implements, imports) is always preserved (#801).
|
||||
# Pass root= so prune_sources (absolute paths from detect_incremental) are
|
||||
# relativized to match the graph's relative source_file values; without it
|
||||
# nothing is pruned and stale nodes accumulate on every update (#1361).
|
||||
# directed=IS_DIRECTED: replace IS_DIRECTED with True if --directed was given, else
|
||||
# False. Without it a --directed --update silently rebuilds undirected and collapses
|
||||
# reciprocal A<->B edges (#1392).
|
||||
G = build_merge(
|
||||
[new_extraction],
|
||||
graph_path='graphify-out/graph.json',
|
||||
prune_sources=prune,
|
||||
root='INPUT_PATH',
|
||||
directed=IS_DIRECTED,
|
||||
)
|
||||
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
|
||||
|
||||
# Write merged result back to .graphify_extract.json so Step 4 sees the full graph
|
||||
merged_out = {
|
||||
'nodes': [{'id': n, **d} for n, d in G.nodes(data=True)],
|
||||
'edges': [
|
||||
# Explicit source/target last so they win over any stale attrs in d.
|
||||
{**{k: val for k, val in d.items() if k not in ('_src', '_tgt', 'source', 'target')},
|
||||
'source': d.get('_src', u), 'target': d.get('_tgt', v)}
|
||||
for u, v, d in G.edges(data=True)
|
||||
],
|
||||
# G.graph["hyperedges"] holds hyperedges from both existing graph.json
|
||||
# and new_extraction (build_merge combines them). Falling back to
|
||||
# new_extraction only would silently drop prior-run hyperedges (#801).
|
||||
'hyperedges': list(G.graph.get('hyperedges', [])),
|
||||
'input_tokens': new_extraction.get('input_tokens', 0),
|
||||
'output_tokens': new_extraction.get('output_tokens', 0),
|
||||
}
|
||||
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged_out, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print(f'[graphify update] Merged extraction written ({len(merged_out[\"nodes\"])} nodes, {len(merged_out[\"edges\"])} edges)')
|
||||
|
||||
# Save manifest so next --update diffs against today's state, not the
|
||||
# prior run's baseline (prevents ghost-node reports on subsequent updates).
|
||||
# root= matches the build_merge call above so the manifest keys stay relative to
|
||||
# the scan root — portable across clones/machines, so --update keeps matching
|
||||
# cached files instead of missing every one after a move (#1417).
|
||||
save_manifest(incremental['files'], root='INPUT_PATH')
|
||||
print('[graphify update] Manifest saved.')
|
||||
"
|
||||
```
|
||||
|
||||
Then run Steps 4–8 on the merged graph as normal.
|
||||
|
||||
After Step 4, show the graph diff:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from graphify.analyze import graph_diff
|
||||
from graphify.build import build_from_json
|
||||
from networkx.readwrite import json_graph
|
||||
import networkx as nx
|
||||
from pathlib import Path
|
||||
|
||||
# Load old graph (before update) from backup written before merge
|
||||
old_data = json.loads(Path('graphify-out/.graphify_old.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_old.json').exists() else None
|
||||
new_extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
|
||||
G_new = build_from_json(new_extract, directed=IS_DIRECTED)
|
||||
|
||||
if old_data:
|
||||
G_old = json_graph.node_link_graph(old_data, edges='links')
|
||||
diff = graph_diff(G_old, G_new)
|
||||
print(diff['summary'])
|
||||
if diff['new_nodes']:
|
||||
print('New nodes:', ', '.join(n['label'] for n in diff['new_nodes'][:5]))
|
||||
if diff['new_edges']:
|
||||
print('New edges:', len(diff['new_edges']))
|
||||
"
|
||||
```
|
||||
|
||||
Before the merge step, save the old graph: `cp graphify-out/graph.json graphify-out/.graphify_old.json`
|
||||
Clean up after: `rm -f graphify-out/.graphify_old.json`
|
||||
|
||||
---
|
||||
|
||||
## For --cluster-only
|
||||
|
||||
Skip Steps 1–3. Re-run clustering on the existing graph:
|
||||
|
||||
```bash
|
||||
graphify cluster-only .
|
||||
```
|
||||
|
||||
`graphify cluster-only .` is **self-contained**: it re-clusters, names communities, and regenerates `GRAPH_REPORT.md`, `graph.json`, and `graph.html` from the existing graph. **Do not re-run Steps 5–9** — they read intermediate files (`.graphify_extract.json`, `.graphify_detect.json`, `.graphify_analysis.json`) that a prior build's cleanup (Step 9) already deleted, so they raise `FileNotFoundError` (#1392). When it finishes, present the refreshed `GRAPH_REPORT.md` summary as usual.
|
||||
1
.codex/graphify/.graphify_version
Normal file
1
.codex/graphify/.graphify_version
Normal file
@@ -0,0 +1 @@
|
||||
0.9.5
|
||||
674
.codex/graphify/SKILL.md
Normal file
674
.codex/graphify/SKILL.md
Normal file
@@ -0,0 +1,674 @@
|
||||
---
|
||||
name: graphify
|
||||
description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
|
||||
---
|
||||
|
||||
# /graphify
|
||||
|
||||
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/graphify # full pipeline on current directory → Obsidian vault
|
||||
/graphify <path> # full pipeline on specific path
|
||||
/graphify https://github.com/<owner>/<repo> # clone repo then run full pipeline on it
|
||||
/graphify https://github.com/<owner>/<repo> --branch <branch> # clone a specific branch
|
||||
/graphify <url1> <url2> ... # clone multiple repos, build each, merge into one cross-repo graph
|
||||
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
|
||||
/graphify <path> --update # incremental - re-extract only new/changed files
|
||||
/graphify <path> --directed # build directed graph (preserves edge direction: source→target)
|
||||
/graphify <path> --whisper-model medium # use a larger Whisper model for better transcription accuracy
|
||||
/graphify <path> --cluster-only # rerun clustering on existing graph
|
||||
/graphify <path> --no-viz # skip visualization, just report + JSON
|
||||
/graphify <path> --html # (HTML is generated by default - this flag is a no-op)
|
||||
/graphify <path> --svg # also export graph.svg (embeds in Notion, GitHub)
|
||||
/graphify <path> --graphml # export graph.graphml (Gephi, yEd)
|
||||
/graphify <path> --neo4j # generate graphify-out/cypher.txt for Neo4j
|
||||
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
|
||||
/graphify <path> --falkordb # generate graphify-out/cypher.txt for FalkorDB
|
||||
/graphify <path> --falkordb-push falkordb://localhost:6379 # push directly to FalkorDB
|
||||
/graphify <path> --mcp # start MCP stdio server for agent access
|
||||
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
|
||||
/graphify <path> --wiki # build agent-crawlable wiki (index.md + one article per community)
|
||||
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
|
||||
/graphify add <url> # fetch URL, save to ./raw, update graph
|
||||
/graphify add <url> --author "Name" # tag who wrote it
|
||||
/graphify add <url> --contributor "Name" # tag who added it to the corpus
|
||||
/graphify query "<question>" # BFS traversal - broad context
|
||||
/graphify query "<question>" --dfs # DFS - trace a specific path
|
||||
/graphify query "<question>" --budget 1500 # cap answer at N tokens
|
||||
/graphify path "AuthModule" "Database" # shortest path between two concepts
|
||||
/graphify explain "SwinTransformer" # plain-language explanation of a node
|
||||
```
|
||||
|
||||
## What graphify is for
|
||||
|
||||
Drop any folder of code, docs, papers, images, or video into graphify and get a queryable knowledge graph. Persistent across sessions, honest audit trail (EXTRACTED/INFERRED/AMBIGUOUS), community detection surfaces cross-document connections you wouldn't think to ask about.
|
||||
|
||||
## What You Must Do When Invoked
|
||||
|
||||
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
|
||||
|
||||
**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query "<question>"` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it.
|
||||
|
||||
If no path was given, use `.` (current directory). Do not ask the user for a path.
|
||||
|
||||
If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path.
|
||||
|
||||
Follow these steps in order. Do not skip steps.
|
||||
|
||||
### Step 0 - GitHub repos and multi-path merge (only if a URL or several paths)
|
||||
|
||||
Only when the path is one or more `https://github.com/...` URLs, or several local subfolders to merge. See `references/github-and-merge.md` for the clone, cross-repo merge, and monorepo flow, then continue with the resolved local path. A plain local path skips this step.
|
||||
|
||||
### Step 1 - Ensure graphify is installed
|
||||
|
||||
```bash
|
||||
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs)
|
||||
PYTHON=""
|
||||
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
|
||||
# 1. uv tool installs — most reliable on modern Mac/Linux
|
||||
if [ -z "$PYTHON" ] && command -v uv >/dev/null 2>&1; then
|
||||
_UV_PY=$(uv tool run graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
|
||||
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
|
||||
fi
|
||||
# 2. Read shebang from graphify binary (pipx and direct pip installs)
|
||||
if [ -z "$PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then
|
||||
_SHEBANG=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
|
||||
case "$_SHEBANG" in
|
||||
*[!a-zA-Z0-9/_.@-]*) ;;
|
||||
*) "$_SHEBANG" -c "import graphify" 2>/dev/null && PYTHON="$_SHEBANG" ;;
|
||||
esac
|
||||
fi
|
||||
# 3. Fall back to python3
|
||||
if [ -z "$PYTHON" ]; then PYTHON="python3"; fi
|
||||
if ! "$PYTHON" -c "import graphify" 2>/dev/null; then
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
uv tool install --upgrade graphifyy -q 2>&1 | tail -3
|
||||
_UV_PY=$(uv tool run graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
|
||||
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
|
||||
else
|
||||
"$PYTHON" -m pip install graphifyy -q 2>/dev/null \
|
||||
|| "$PYTHON" -m pip install graphifyy -q --break-system-packages 2>&1 | tail -3
|
||||
fi
|
||||
fi
|
||||
# Write interpreter path for all subsequent steps (persists across invocations)
|
||||
mkdir -p graphify-out
|
||||
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
|
||||
# Save scan root so `graphify update` (no args) knows where to look next time
|
||||
echo "$(cd INPUT_PATH && pwd)" > graphify-out/.graphify_root
|
||||
```
|
||||
|
||||
If the import succeeds, print nothing and move straight to Step 2.
|
||||
|
||||
**In every subsequent bash block, replace `python3` with `$(cat graphify-out/.graphify_python)` to use the correct interpreter.**
|
||||
|
||||
### Step 2 - Detect files
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from graphify.detect import detect
|
||||
from pathlib import Path
|
||||
result = detect(Path('INPUT_PATH'))
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
" > graphify-out/.graphify_detect.json
|
||||
```
|
||||
|
||||
Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead:
|
||||
|
||||
```
|
||||
Corpus: X files · ~Y words
|
||||
code: N files (.py .ts .go ...)
|
||||
docs: N files (.md .txt ...)
|
||||
papers: N files (.pdf ...)
|
||||
images: N files
|
||||
video: N files (.mp4 .mp3 ...)
|
||||
```
|
||||
|
||||
Omit any category with 0 files from the summary.
|
||||
|
||||
Then act on it:
|
||||
- If `total_files` is 0: stop with "No supported files found in [path]."
|
||||
- If `skipped_sensitive` is non-empty: mention file count skipped, not the file names.
|
||||
- If `total_words` > 2,000,000 OR `total_files` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count:
|
||||
- Read `scan_root` from the detect JSON (always an absolute path to the resolved INPUT_PATH).
|
||||
- Concatenate all file lists across all types (`code`, `document`, `paper`, `image`, `video`).
|
||||
- Filter out any path that starts with `scan_root + "/graphify-out/"` to exclude converted sidecars.
|
||||
- For each file, strip the `scan_root` prefix and take the first path component. Files directly in `scan_root` with no subdirectory count as `(root)`.
|
||||
- If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed.
|
||||
- Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding.
|
||||
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
|
||||
|
||||
### Step 2.5 - Video and audio (only if video files detected)
|
||||
|
||||
Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3.
|
||||
|
||||
### Step 3 - Extract entities and relationships
|
||||
|
||||
**Before starting:** note whether `--mode deep` was given. You must pass `DEEP_MODE=true` to every subagent in Step B2 if it was. Track this from the original invocation - do not lose it.
|
||||
|
||||
This step has two parts: **structural extraction** (deterministic, free) and **semantic extraction** (LLM, costs tokens).
|
||||
|
||||
> **graphify needs no API key. Never ask the user for one, and never block on one.** Code is extracted structurally (AST) with no LLM and no key at all — a code-only corpus (the common `/graphify .` on a repo) skips semantic extraction entirely, so it needs nothing here: go straight to Part A and skip Part B. Semantic extraction (only for docs, papers, and images) uses Gemini **only if** `GEMINI_API_KEY`/`GOOGLE_API_KEY` is already set; otherwise the host agent itself is the LLM. graphify does **not** read `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or any other provider key. If you catch yourself about to prompt for, wait on, or stop because of a missing API key, that is a misread of this skill — proceed without one.
|
||||
|
||||
**Before semantic extraction:** check whether `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set. If neither is set, print this one-liner to the user:
|
||||
> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction (`pip install 'graphifyy[gemini]'`).
|
||||
|
||||
Print it once, then continue — do not wait for the user to supply a key. If `GEMINI_API_KEY` or `GOOGLE_API_KEY` IS set, use `graphify.llm.extract_corpus_parallel(files, backend="gemini")` for semantic extraction instead of dispatching subagents. The default Gemini model is `gemini-3-flash-preview`; set `GRAPHIFY_GEMINI_MODEL` or pass `--model` in headless CLI flows to override it.
|
||||
|
||||
> **No other API keys are read.** When `GEMINI_API_KEY`/`GOOGLE_API_KEY` are unset, semantic extraction falls to the host agent itself — the running session is the LLM. On a host that dispatches subagents (e.g. Claude Code), dispatch them as written in Part B. On a host that runs the CLI directly in a terminal and cannot dispatch subagents, do not stall: a code-only corpus has no semantic work, so write the empty semantic file (Part B "Fast path") and continue to Part C; for a corpus with docs/papers/images, either set a Gemini key or extract those inline yourself, but in no case prompt for `ANTHROPIC_API_KEY` — that prompt is a misread of this skill.
|
||||
|
||||
**Run Part A (AST) and Part B (semantic) in parallel. Dispatch all semantic subagents AND start AST extraction in the same message. Both can run simultaneously since they operate on different file types. Merge results in Part C as before.**
|
||||
|
||||
Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is deterministic and fast; start it while subagents are processing docs/papers.
|
||||
|
||||
#### Part A - Structural extraction for code files
|
||||
|
||||
For any code files detected, run AST extraction in parallel with Part B subagents:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import sys, json
|
||||
from graphify.extract import collect_files, extract
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
code_files = []
|
||||
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
|
||||
for f in detect.get('files', {}).get('code', []):
|
||||
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
|
||||
|
||||
if code_files:
|
||||
result = extract(code_files, cache_root=Path('INPUT_PATH'))
|
||||
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
|
||||
else:
|
||||
Path('graphify-out/.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print('No code files - skipping AST extraction')
|
||||
"
|
||||
```
|
||||
|
||||
#### Part B - Semantic extraction (parallel subagents)
|
||||
|
||||
**Fast path:** If detection found zero docs, papers, and images (code-only corpus), skip Part B entirely and go straight to Part C. AST handles code - there is nothing for semantic subagents to do. **First write an empty semantic file** so Part C's merge has its input (it reads `.graphify_semantic.json` unconditionally; without this a code-only run hits `FileNotFoundError`):
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
|
||||
"
|
||||
```
|
||||
|
||||
**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.**
|
||||
|
||||
Before dispatching subagents, print a timing estimate:
|
||||
- Load `total_words` and file counts from `graphify-out/.graphify_detect.json`
|
||||
- Estimate agents needed: `ceil(uncached_non_code_files / 22)` (chunk size is 20-25)
|
||||
- Estimate time: ~45s per agent batch (they run in parallel, so total ≈ 45s × ceil(agents/parallel_limit))
|
||||
- Print: "Semantic extraction: ~N files → X agents, estimated ~Ys"
|
||||
|
||||
**Step B0 - Check extraction cache first**
|
||||
|
||||
Before dispatching any subagents, check which files already have cached extraction results:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from graphify.cache import check_semantic_cache
|
||||
from pathlib import Path
|
||||
|
||||
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
|
||||
# Only content files go to semantic extraction. Code is already covered structurally
|
||||
# by the AST pass (Part A); flattening every category here makes subagents re-read
|
||||
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
|
||||
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]
|
||||
|
||||
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH')
|
||||
|
||||
# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
|
||||
# run so Part C never merges a stale .graphify_cached.json (#1392).
|
||||
if cached_nodes or cached_edges or cached_hyperedges:
|
||||
Path('graphify-out/.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}, ensure_ascii=False), encoding=\"utf-8\")
|
||||
else:
|
||||
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
|
||||
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
|
||||
print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction')
|
||||
"
|
||||
```
|
||||
|
||||
Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt`. If all files are cached, skip to Part C directly.
|
||||
|
||||
**Step B1 - Split into chunks**
|
||||
|
||||
Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks of 20-25 files each. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
|
||||
|
||||
**Step B2 - Dispatch ALL subagents in a single message (Codex)**
|
||||
|
||||
> **Codex platform:** Uses `spawn_agent` + `wait_agent` + `close_agent` instead of the Agent tool.
|
||||
> Requires `multi_agent = true` under `[features]` in `~/.codex/config.toml`.
|
||||
> If `spawn_agent` is unavailable, tell the user to add that config and restart Codex.
|
||||
|
||||
Call `spawn_agent` once per chunk — ALL in the same response so they run in parallel. Build the message by wrapping the extraction prompt in task-delegation framing:
|
||||
|
||||
```
|
||||
spawn_agent(agent_type="worker", message="Your task is to perform the following. Follow the instructions below exactly.\n\n<agent-instructions>\n[extraction prompt, with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE substituted]\n</agent-instructions>\n\nExecute this now. Output ONLY the structured JSON response.")
|
||||
```
|
||||
|
||||
After all agents are dispatched, collect results sequentially in memory:
|
||||
```
|
||||
result = wait_agent(handle); close_agent(handle) # repeat per handle
|
||||
```
|
||||
|
||||
Parse each result as JSON. Accumulate nodes/edges/hyperedges across all results and write to `graphify-out/.graphify_semantic_new.json`. Codex collects in memory, so there are no per-chunk files on disk; the disk-based success checks in Step B3 do not apply — a chunk that returns invalid JSON is the failure signal instead.
|
||||
|
||||
Subagent prompt template:
|
||||
|
||||
See `references/extraction-spec.md` for the compact subagent prompt (rules, node-ID format, confidence rubric, hyperedge and vision rules, JSON schema). Load it only here, only when at least one chunk holds a doc, paper, or image; a pure-code corpus has skipped Part B and never reads it. Pass each agent that prompt verbatim with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, and DEEP_MODE substituted, and have it return the JSON inline.
|
||||
|
||||
**Step B3 - Collect, cache, and merge**
|
||||
|
||||
Wait for all subagents. For each result:
|
||||
- Check that `graphify-out/.graphify_chunk_NN.json` exists on disk — this is the success signal
|
||||
- If the file exists and contains valid JSON with `nodes` and `edges`, include it and save to cache
|
||||
- If the file is missing, the subagent was likely dispatched as read-only (Explore type) — print a warning: "chunk N missing from disk — subagent may have been read-only. Re-run with general-purpose agent." Do not silently skip.
|
||||
- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort
|
||||
|
||||
If more than half the chunks failed or are missing, stop and tell the user to re-run and ensure `subagent_type="general-purpose"` is used.
|
||||
|
||||
Merge all chunk files into `.graphify_semantic_new.json`. **After each Agent call completes, read the real token counts from the Agent tool result's `usage` field and write them back into the chunk JSON before merging** — the chunk JSON itself always has placeholder zeros. Then run:
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json, glob
|
||||
from pathlib import Path
|
||||
|
||||
chunks = sorted(glob.glob('graphify-out/.graphify_chunk_*.json'))
|
||||
all_nodes, all_edges, all_hyperedges = [], [], []
|
||||
total_in, total_out = 0, 0
|
||||
for c in chunks:
|
||||
d = json.loads(Path(c).read_text(encoding=\"utf-8\"))
|
||||
all_nodes += d.get('nodes', [])
|
||||
all_edges += d.get('edges', [])
|
||||
all_hyperedges += d.get('hyperedges', [])
|
||||
total_in += d.get('input_tokens', 0)
|
||||
total_out += d.get('output_tokens', 0)
|
||||
Path('graphify-out/.graphify_semantic_new.json').write_text(json.dumps({
|
||||
'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges,
|
||||
'input_tokens': total_in, 'output_tokens': total_out,
|
||||
}, indent=2, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens')
|
||||
"
|
||||
```
|
||||
|
||||
Save new results to cache:
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from graphify.cache import save_semantic_cache
|
||||
from pathlib import Path
|
||||
|
||||
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
|
||||
saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', []), root='INPUT_PATH')
|
||||
print(f'Cached {saved} files')
|
||||
"
|
||||
```
|
||||
|
||||
Merge cached + new results into `graphify-out/.graphify_semantic.json`:
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
cached = json.loads(Path('graphify-out/.graphify_cached.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
|
||||
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
|
||||
|
||||
all_nodes = cached['nodes'] + new.get('nodes', [])
|
||||
all_edges = cached['edges'] + new.get('edges', [])
|
||||
all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', [])
|
||||
seen = set()
|
||||
deduped = []
|
||||
for n in all_nodes:
|
||||
if n['id'] not in seen:
|
||||
seen.add(n['id'])
|
||||
deduped.append(n)
|
||||
|
||||
merged = {
|
||||
'nodes': deduped,
|
||||
'edges': all_edges,
|
||||
'hyperedges': all_hyperedges,
|
||||
'input_tokens': new.get('input_tokens', 0),
|
||||
'output_tokens': new.get('output_tokens', 0),
|
||||
}
|
||||
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
|
||||
"
|
||||
```
|
||||
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
|
||||
|
||||
#### Part C - Merge AST + semantic into final extraction
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import sys, json
|
||||
from pathlib import Path
|
||||
|
||||
ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding=\"utf-8\"))
|
||||
sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding=\"utf-8\"))
|
||||
|
||||
# Merge: AST nodes first, semantic nodes deduplicated by id
|
||||
seen = {n['id'] for n in ast['nodes']}
|
||||
merged_nodes = list(ast['nodes'])
|
||||
for n in sem['nodes']:
|
||||
if n['id'] not in seen:
|
||||
merged_nodes.append(n)
|
||||
seen.add(n['id'])
|
||||
|
||||
merged_edges = ast['edges'] + sem['edges']
|
||||
merged_hyperedges = sem.get('hyperedges', [])
|
||||
merged = {
|
||||
'nodes': merged_nodes,
|
||||
'edges': merged_edges,
|
||||
'hyperedges': merged_hyperedges,
|
||||
'input_tokens': sem.get('input_tokens', 0),
|
||||
'output_tokens': sem.get('output_tokens', 0),
|
||||
}
|
||||
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
|
||||
total = len(merged_nodes)
|
||||
edges = len(merged_edges)
|
||||
print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(sem[\"nodes\"])} semantic)')
|
||||
"
|
||||
```
|
||||
|
||||
### Step 4 - Build graph, cluster, analyze, generate outputs
|
||||
|
||||
**Before starting:** the code blocks below pass `directed=IS_DIRECTED` to `build_from_json()`. Replace `IS_DIRECTED` with `True` if `--directed` was given (builds a `DiGraph` preserving edge direction source→target), otherwise `False` (the default undirected `Graph`). Substitute it the same way you substitute `INPUT_PATH` — do not leave the literal `IS_DIRECTED` in the code.
|
||||
|
||||
```bash
|
||||
mkdir -p graphify-out
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import sys, json
|
||||
from graphify.build import build_from_json
|
||||
from graphify.cluster import cluster, score_all
|
||||
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
|
||||
from graphify.report import generate
|
||||
from graphify.export import to_json
|
||||
from pathlib import Path
|
||||
|
||||
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
|
||||
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
|
||||
|
||||
# root= mirrors the --update runbook (#1361): relativize source_file to the same
|
||||
# base so the full build and incremental --update never drift apart on re-extract.
|
||||
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
|
||||
# Guard BEFORE any write: an empty extraction must not clobber a good graph.json /
|
||||
# GRAPH_REPORT.md / analysis sidecar. Check immediately after build (#1392).
|
||||
if G.number_of_nodes() == 0:
|
||||
print('ERROR: Graph is empty - extraction produced no nodes.')
|
||||
print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.')
|
||||
raise SystemExit(1)
|
||||
communities = cluster(G)
|
||||
cohesion = score_all(G, communities)
|
||||
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
|
||||
gods = god_nodes(G)
|
||||
surprises = surprising_connections(G, communities)
|
||||
labels = {cid: 'Community ' + str(cid) for cid in communities}
|
||||
# Placeholder questions - regenerated with real labels in Step 5
|
||||
questions = suggest_questions(G, communities, labels)
|
||||
|
||||
# Export FIRST and honor the #479 shrink-guard: to_json returns False (writing
|
||||
# nothing) when the new graph is smaller than the existing graph.json. Only write
|
||||
# GRAPH_REPORT.md + the analysis sidecar when the graph was actually written, so
|
||||
# they never describe a graph that graph.json doesn't contain (#1392).
|
||||
wrote = to_json(G, communities, 'graphify-out/graph.json')
|
||||
if not wrote:
|
||||
print('ERROR: refused to shrink graphify-out/graph.json (existing graph has more nodes; #479).')
|
||||
print('If this shrink is intentional (you deleted files), re-run a full build with --force.')
|
||||
raise SystemExit(1)
|
||||
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions)
|
||||
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
|
||||
analysis = {
|
||||
'communities': {str(k): v for k, v in communities.items()},
|
||||
'cohesion': {str(k): v for k, v in cohesion.items()},
|
||||
'gods': gods,
|
||||
'surprises': surprises,
|
||||
'questions': questions,
|
||||
}
|
||||
Path('graphify-out/.graphify_analysis.json').write_text(json.dumps(analysis, indent=2, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities')
|
||||
"
|
||||
```
|
||||
|
||||
If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization.
|
||||
|
||||
Replace INPUT_PATH with the actual path.
|
||||
|
||||
### Step 4.5 - Graph health check (read-only integrity gate)
|
||||
|
||||
A non-destructive diagnostic on the extraction, before labeling. It surfaces edge collapse, dangling/missing endpoints, and self-loops — the silent-corruption modes of incremental updates and AST/LLM id mismatches. Read-only; never aborts.
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
from graphify.diagnostics import diagnose_extraction, format_diagnostic_report
|
||||
|
||||
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
|
||||
summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH')
|
||||
print(format_diagnostic_report(summary))
|
||||
flags = [f'{summary[k]} {label}' for k, label in (
|
||||
('dangling_endpoint_edges', 'dangling-endpoint edges'),
|
||||
('missing_endpoint_edges', 'missing-endpoint edges'),
|
||||
('self_loop_edges', 'self-loop edges'),
|
||||
('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'),
|
||||
('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'),
|
||||
) if summary.get(k, 0)]
|
||||
print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).')
|
||||
"
|
||||
```
|
||||
|
||||
Substitute `IS_DIRECTED` and `INPUT_PATH` as in Step 4. If a `GRAPH HEALTH WARNING` prints, surface it in the final summary (do not abort — the graph is still usable, but the integrity issue must be visible, per the Honesty Rules).
|
||||
|
||||
### Step 5 - Label communities
|
||||
|
||||
Read `graphify-out/.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading").
|
||||
|
||||
Then regenerate the report and save the labels for the visualizer:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import sys, json
|
||||
from graphify.build import build_from_json
|
||||
from graphify.cluster import score_all
|
||||
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
|
||||
from graphify.report import generate
|
||||
from pathlib import Path
|
||||
|
||||
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
|
||||
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
|
||||
analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text(encoding=\"utf-8\"))
|
||||
|
||||
# root= as in Step 4 / the --update runbook (#1361) — same base for node-key parity.
|
||||
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
|
||||
communities = {int(k): v for k, v in analysis['communities'].items()}
|
||||
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
|
||||
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
|
||||
|
||||
# LABELS - replace these with the names you chose above
|
||||
labels = LABELS_DICT
|
||||
|
||||
# Regenerate questions with real community labels (labels affect question phrasing)
|
||||
questions = suggest_questions(G, communities, labels)
|
||||
|
||||
report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions)
|
||||
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
|
||||
Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print('Report updated with community labels')
|
||||
"
|
||||
```
|
||||
|
||||
Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`).
|
||||
Replace INPUT_PATH with the actual path.
|
||||
|
||||
### Step 6 - Generate Obsidian vault (opt-in) + HTML
|
||||
|
||||
**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was explicitly given** — skip it otherwise, it generates one file per node.
|
||||
|
||||
If `--obsidian` was given:
|
||||
|
||||
- If `--obsidian-dir <path>` was also given, pass it via `--dir`. Otherwise defaults to `graphify-out/obsidian`.
|
||||
|
||||
```bash
|
||||
graphify export obsidian
|
||||
# or with custom dir: graphify export obsidian --dir ~/vaults/my-project
|
||||
```
|
||||
|
||||
Generate the HTML graph (always, unless `--no-viz`):
|
||||
|
||||
```bash
|
||||
graphify export html # auto-aggregates to community view if graph > 5000 nodes
|
||||
# or: graphify export html --no-viz
|
||||
```
|
||||
|
||||
### Steps 6b-8 - Wiki, Neo4j, FalkorDB, SVG, GraphML, MCP, benchmark (only on their flags)
|
||||
|
||||
These run only when their flag is present (`--wiki`, `--neo4j`/`--neo4j-push`, `--falkordb`/`--falkordb-push`, `--svg`, `--graphml`, `--mcp`) or, for the token-reduction benchmark, when `total_words` exceeds 5,000. A default run with no export flags skips all of them. See `references/exports.md` for each one. Run any `--wiki` export before Step 9 cleanup so `.graphify_labels.json` is still available.
|
||||
|
||||
---
|
||||
|
||||
### Step 9 - Save manifest, update cost tracker, clean up, and report
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from graphify.detect import save_manifest
|
||||
|
||||
# Save manifest for --update
|
||||
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
|
||||
# In --update mode, 'all_files' carries the full corpus; 'files' is the changed
|
||||
# subset. Full-rebuild mode populates only 'files', so the fallback handles that.
|
||||
# root= relativizes the manifest keys to the scan root (same base as the build),
|
||||
# so the on-disk manifest is portable across clones/machines and a later --update
|
||||
# matches cached files instead of missing every one (#1417).
|
||||
save_manifest(detect.get('all_files') or detect['files'], root='INPUT_PATH')
|
||||
|
||||
# Update cumulative cost tracker
|
||||
extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
|
||||
input_tok = extract.get('input_tokens', 0)
|
||||
output_tok = extract.get('output_tokens', 0)
|
||||
|
||||
cost_path = Path('graphify-out/cost.json')
|
||||
if cost_path.exists():
|
||||
cost = json.loads(cost_path.read_text(encoding=\"utf-8\"))
|
||||
else:
|
||||
cost = {'runs': [], 'total_input_tokens': 0, 'total_output_tokens': 0}
|
||||
|
||||
cost['runs'].append({
|
||||
'date': datetime.now(timezone.utc).isoformat(),
|
||||
'input_tokens': input_tok,
|
||||
'output_tokens': output_tok,
|
||||
'files': detect.get('total_files', 0),
|
||||
})
|
||||
cost['total_input_tokens'] += input_tok
|
||||
cost['total_output_tokens'] += output_tok
|
||||
cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\")
|
||||
|
||||
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
|
||||
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
|
||||
"
|
||||
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
|
||||
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
|
||||
rm -f graphify-out/.needs_update 2>/dev/null || true
|
||||
```
|
||||
|
||||
Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root.
|
||||
|
||||
Tell the user (omit the obsidian line unless --obsidian was given):
|
||||
```
|
||||
Graph complete. Outputs in PATH_TO_DIR/graphify-out/
|
||||
|
||||
graph.html - interactive graph, open in browser
|
||||
GRAPH_REPORT.md - audit report
|
||||
graph.json - raw graph data
|
||||
obsidian/ - Obsidian vault (only if --obsidian was given)
|
||||
```
|
||||
|
||||
If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi
|
||||
|
||||
Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.
|
||||
|
||||
Then paste these sections from GRAPH_REPORT.md directly into the chat:
|
||||
- God Nodes
|
||||
- Surprising Connections
|
||||
- Suggested Questions
|
||||
|
||||
Do NOT paste the full report - just those three sections. Keep it concise.
|
||||
|
||||
Then immediately offer to explore. Pick the single most interesting suggested question from the report - the one that crosses the most community boundaries or has the most surprising bridge node - and ask:
|
||||
|
||||
> "The most interesting question this graph can answer: **[question]**. Want me to trace it?"
|
||||
|
||||
If the user says yes, run `/graphify query "[question]"` on the graph and walk them through the answer using the graph structure - which nodes connect, which community boundaries get crossed, what the path reveals. Keep going as long as they want to explore. Each answer should end with a natural follow-up ("this connects to X - want to go deeper?") so the session feels like navigation, not a one-shot report.
|
||||
|
||||
The graph is the map. Your job after the pipeline is to be the guide.
|
||||
|
||||
---
|
||||
|
||||
## Interpreter guard for subcommands
|
||||
|
||||
Before running any subcommand below (`--update`, `--cluster-only`, `query`, `path`, `explain`, `add`), check that `.graphify_python` exists. If it's missing (e.g. user deleted `graphify-out/`), re-resolve the interpreter first:
|
||||
|
||||
```bash
|
||||
if [ ! -f graphify-out/.graphify_python ]; then
|
||||
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
|
||||
if [ -n "$GRAPHIFY_BIN" ]; then
|
||||
PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
|
||||
case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac
|
||||
else
|
||||
PYTHON="python3"
|
||||
fi
|
||||
mkdir -p graphify-out
|
||||
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
|
||||
fi
|
||||
```
|
||||
|
||||
## For --update and --cluster-only
|
||||
|
||||
Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows.
|
||||
|
||||
---
|
||||
|
||||
## For /graphify query
|
||||
|
||||
When `graphify-out/graph.json` already exists and the user asks a question about the corpus, answer from the graph rather than rebuilding it:
|
||||
|
||||
```bash
|
||||
graphify query "<question>"
|
||||
```
|
||||
|
||||
Before traversal, expand the question against the graph's own vocabulary so a wording mismatch does not collapse the answer to noise. If the `graphify query` CLI is unavailable, fall back to an inline NetworkX traversal of `graphify-out/graph.json`. Answer using only what the graph output contains, and quote `source_location` when citing a specific fact. For that vocab-expansion step, the BFS/DFS traversal modes, the `--budget` cap, the NetworkX fallback, `save-result` feedback, and the `/graphify path` and `/graphify explain` flows, see `references/query.md`.
|
||||
|
||||
---
|
||||
|
||||
## For /graphify add and --watch
|
||||
|
||||
Neither is part of the default build. When the user runs `/graphify add <url>` to fetch a URL into the corpus, or passes `--watch` to auto-rebuild on file changes, see `references/add-watch.md`.
|
||||
|
||||
---
|
||||
|
||||
## For the commit hook and native CLAUDE.md integration
|
||||
|
||||
When the user asks to install the post-commit auto-rebuild hook or wire graphify into a project's CLAUDE.md, see `references/hooks.md`.
|
||||
|
||||
---
|
||||
|
||||
## Honesty Rules
|
||||
|
||||
- Never invent an edge. If unsure, use AMBIGUOUS.
|
||||
- Never skip the corpus check warning.
|
||||
- Always show token cost in the report.
|
||||
- Never hide cohesion scores behind symbols - show the raw number.
|
||||
- Never run HTML viz on a graph with more than 5,000 nodes without warning the user.
|
||||
56
.codex/graphify/references/add-watch.md
Normal file
56
.codex/graphify/references/add-watch.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# graphify reference: add a URL and watch a folder
|
||||
|
||||
Load this when the user ran `/graphify add <url>` or passed `--watch`. Neither is part of the default build.
|
||||
|
||||
## For /graphify add
|
||||
|
||||
Fetch a URL and add it to the corpus, then update the graph.
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import sys
|
||||
from graphify.ingest import ingest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
out = ingest('URL', Path('./raw'), author='AUTHOR', contributor='CONTRIBUTOR')
|
||||
print(f'Saved to {out}')
|
||||
except ValueError as e:
|
||||
print(f'error: {e}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except RuntimeError as e:
|
||||
print(f'error: {e}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
"
|
||||
```
|
||||
|
||||
Replace `URL` with the actual URL, `AUTHOR` with the user's name if provided, `CONTRIBUTOR` likewise. If the command exits with an error, tell the user what went wrong - do not silently continue. After a successful save, automatically run the `--update` pipeline on `./raw` to merge the new file into the existing graph.
|
||||
|
||||
Supported URL types (auto-detected):
|
||||
- YouTube / any video URL → audio downloaded via yt-dlp, transcribed to `.txt` on next run (requires `pip install 'graphifyy[video]'`)
|
||||
- Twitter/X → fetched via oEmbed, saved as `.md` with tweet text and author
|
||||
- arXiv → abstract + metadata saved as `.md`
|
||||
- PDF → downloaded as `.pdf`
|
||||
- Images (.png/.jpg/.webp) → downloaded, Claude vision extracts on next run
|
||||
- Any webpage → converted to markdown via html2text
|
||||
|
||||
---
|
||||
|
||||
## For --watch
|
||||
|
||||
Start a background watcher that monitors a folder and auto-updates the graph when files change.
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -m graphify.watch INPUT_PATH --debounce 3
|
||||
```
|
||||
|
||||
Replace INPUT_PATH with the folder to watch. Behavior depends on what changed:
|
||||
|
||||
- **Code files only (.py, .ts, .go, etc.):** re-runs AST extraction + rebuild + cluster immediately, no LLM needed. `graph.json` and `GRAPH_REPORT.md` are updated automatically.
|
||||
- **Docs, papers, or images:** writes a `graphify-out/needs_update` flag and prints a notification to run `/graphify --update` (LLM semantic re-extraction required).
|
||||
|
||||
Debounce (default 3s): waits until file activity stops before triggering, so a wave of parallel agent writes doesn't trigger a rebuild per file.
|
||||
|
||||
Press Ctrl+C to stop.
|
||||
|
||||
For agentic workflows: run `--watch` in a background terminal. Code changes from agent waves are picked up automatically between waves. If agents are also writing docs or notes, you'll need a manual `/graphify --update` after those waves.
|
||||
87
.codex/graphify/references/exports.md
Normal file
87
.codex/graphify/references/exports.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# graphify reference: extra exports and benchmark
|
||||
|
||||
Load this when the user passed one of the export flags (`--wiki`, `--neo4j`, `--neo4j-push`, `--falkordb`, `--falkordb-push`, `--svg`, `--graphml`, `--mcp`), or when the corpus is large enough for the token-reduction benchmark. Each step runs only for its own flag.
|
||||
|
||||
### Step 6b - Wiki (only if --wiki flag)
|
||||
|
||||
**Only run this step if `--wiki` was explicitly given in the original command.**
|
||||
|
||||
Run this before Step 9 (cleanup) so `.graphify_labels.json` is still available.
|
||||
|
||||
```bash
|
||||
graphify export wiki
|
||||
```
|
||||
|
||||
### Step 7 - Neo4j export (only if --neo4j or --neo4j-push flag)
|
||||
|
||||
**If `--neo4j`** - generate a Cypher file for manual import:
|
||||
|
||||
```bash
|
||||
graphify export neo4j
|
||||
```
|
||||
|
||||
**If `--neo4j-push <uri>`** - push directly to a running Neo4j instance. Ask the user for credentials if not provided:
|
||||
|
||||
```bash
|
||||
graphify export neo4j --push bolt://localhost:7687 --user neo4j --password PASSWORD
|
||||
```
|
||||
|
||||
Default URI is `bolt://localhost:7687`, default user is `neo4j`. Uses MERGE - safe to re-run without creating duplicates.
|
||||
|
||||
### Step 7a - FalkorDB export (only if --falkordb or --falkordb-push flag)
|
||||
|
||||
**If `--falkordb`** - generate a Cypher file. The statements are OpenCypher, but FalkorDB's `GRAPH.QUERY` runs one statement at a time (no bulk script import like Neo4j's `cypher-shell`), so prefer `--falkordb-push` to load a graph. Use this only when you want the portable `cypher.txt` artifact:
|
||||
|
||||
```bash
|
||||
graphify export falkordb
|
||||
```
|
||||
|
||||
**If `--falkordb-push <uri>`** - push directly to a running FalkorDB instance. Credentials are optional; ask the user only if the instance requires auth:
|
||||
|
||||
```bash
|
||||
graphify export falkordb --push falkordb://localhost:6379
|
||||
```
|
||||
|
||||
Default URI is `falkordb://localhost:6379` (the scheme is informational - `redis://` or a bare `host:port` work too), auth is optional, and the target graph defaults to `graphify`. Uses MERGE - safe to re-run without creating duplicates.
|
||||
|
||||
### Step 7b - SVG export (only if --svg flag)
|
||||
|
||||
```bash
|
||||
graphify export svg
|
||||
```
|
||||
|
||||
### Step 7c - GraphML export (only if --graphml flag)
|
||||
|
||||
```bash
|
||||
graphify export graphml
|
||||
```
|
||||
|
||||
### Step 7d - MCP server (only if --mcp flag)
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -m graphify.serve graphify-out/graph.json
|
||||
```
|
||||
|
||||
This starts a stdio MCP server that exposes tools: `query_graph`, `get_node`, `get_neighbors`, `get_community`, `god_nodes`, `graph_stats`, `shortest_path`. Add to Claude Desktop or any MCP-compatible agent orchestrator so other agents can query the graph live.
|
||||
|
||||
To configure in Claude Desktop, add to `claude_desktop_config.json`. Claude Desktop can't run `$(...)`, and under `uv tool install` the system `python3` can't import graphify — so set `command` to the **absolute interpreter path** printed by `cat graphify-out/.graphify_python`:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"graphify": {
|
||||
"command": "<absolute path from: cat graphify-out/.graphify_python>",
|
||||
"args": ["-m", "graphify.serve", "/absolute/path/to/graphify-out/graph.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 8 - Token reduction benchmark (only if total_words > 5000)
|
||||
|
||||
If `total_words` from `graphify-out/.graphify_detect.json` is greater than 5,000, run:
|
||||
|
||||
```bash
|
||||
graphify benchmark
|
||||
```
|
||||
|
||||
Print the output directly in chat. If `total_words <= 5000`, skip silently - the graph value is structural clarity, not token compression, for small corpora.
|
||||
31
.codex/graphify/references/extraction-spec.md
Normal file
31
.codex/graphify/references/extraction-spec.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# graphify reference: extraction subagent prompt (compact)
|
||||
|
||||
Load this in Step 3 Part B when the corpus has at least one doc, paper, or image chunk. A pure-code corpus skips Part B and never reads this file. Each semantic subagent receives the prompt below verbatim (substitute FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, and DEEP_MODE).
|
||||
|
||||
```
|
||||
You are a graphify extraction subagent. Read the files listed and extract a knowledge graph fragment.
|
||||
Output ONLY valid JSON matching the schema below - no explanation, no markdown fences, no preamble.
|
||||
|
||||
Files (chunk CHUNK_NUM of TOTAL_CHUNKS):
|
||||
FILE_LIST
|
||||
|
||||
Rules:
|
||||
- EXTRACTED: relationship explicit in source (import, call, citation)
|
||||
- INFERRED: reasonable inference (shared structure, implied dependency)
|
||||
- AMBIGUOUS: uncertain — flag it, do not omit
|
||||
- Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language.
|
||||
- Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
|
||||
- Image files: use vision — understand what the image IS, not just OCR
|
||||
- DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting.
|
||||
- Semantic similarity: if two concepts solve the same problem or represent the same idea without a structural link (no import, call, or citation), add a `semantically_similar_to` edge marked INFERRED with confidence_score 0.6-0.95. Non-obvious cross-file links only.
|
||||
- Hyperedges: if 3+ nodes share a concept, flow, or pattern not captured by pairwise edges, add a hyperedge to a top-level `hyperedges` array. Use sparingly. Max 3 per chunk.
|
||||
- If a file has YAML frontmatter (--- ... ---), copy source_url, captured_at, author, contributor onto every node from that file.
|
||||
- confidence_score is REQUIRED on every edge — never omit it, never use 0.5 as a default. EXTRACTED = 1.0 always. INFERRED: pick exactly ONE of 0.95 (direct structural evidence), 0.85 (strong inference), 0.75 (reasonable inference), 0.65 (weak inference), 0.55 (speculative but plausible) — never 0.5; if none fit, mark the edge AMBIGUOUS. AMBIGUOUS = 0.1-0.3.
|
||||
|
||||
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone.
|
||||
|
||||
Output exactly this JSON (no other text):
|
||||
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"<FILE_LIST path verbatim>","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"<FILE_LIST path verbatim>","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":"<FILE_LIST path verbatim>"}],"input_tokens":0,"output_tokens":0}
|
||||
|
||||
source_file RULE: set source_file to the FILE_LIST path for that file VERBATIM (absolute, no shortening to basename, no re-relativizing, no separator change). Keeps full build and --update on one base so build_merge's replace matches instead of duplicating.
|
||||
```
|
||||
46
.codex/graphify/references/github-and-merge.md
Normal file
46
.codex/graphify/references/github-and-merge.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# graphify reference: GitHub clone and cross-repo merge
|
||||
|
||||
Load this when the user passed one or more `https://github.com/...` URLs, or named several local subfolders to merge into one graph.
|
||||
|
||||
### Step 0 - Clone GitHub repo(s) (only if a GitHub URL was given)
|
||||
|
||||
**Single repo:**
|
||||
```bash
|
||||
LOCAL_PATH=$(graphify clone <github-url> [--branch <branch>])
|
||||
# Use LOCAL_PATH as the target for all subsequent steps
|
||||
```
|
||||
|
||||
**Multiple repos (cross-repo graph):**
|
||||
```bash
|
||||
# Clone each repo, run the full pipeline on each, then merge
|
||||
graphify clone <url1> # → ~/.graphify/repos/<owner1>/<repo1>
|
||||
graphify clone <url2> # → ~/.graphify/repos/<owner2>/<repo2>
|
||||
# Run /graphify on each local path to produce their graph.json files
|
||||
# Then merge:
|
||||
graphify merge-graphs \
|
||||
~/.graphify/repos/<owner1>/<repo1>/graphify-out/graph.json \
|
||||
~/.graphify/repos/<owner2>/<repo2>/graphify-out/graph.json \
|
||||
--out graphify-out/cross-repo-graph.json
|
||||
```
|
||||
|
||||
Graphify clones into `~/.graphify/repos/<owner>/<repo>` and reuses existing clones on repeat runs. Each node in the merged graph carries a `repo` attribute so you can filter by origin.
|
||||
|
||||
**Multiple local subfolders (monorepo or multi-service layout):**
|
||||
|
||||
The skill pipeline writes all intermediate and final outputs to `graphify-out/` in the current working directory. Running the skill on each subfolder separately will clobber the same output dir. Instead, use the CLI directly for each subfolder — it places `graphify-out/` *inside* the scanned path:
|
||||
|
||||
```bash
|
||||
graphify extract ./core/ # → ./core/graphify-out/graph.json
|
||||
graphify extract ./service/ # → ./service/graphify-out/graph.json
|
||||
graphify extract ./platform/ # → ./platform/graphify-out/graph.json
|
||||
# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set
|
||||
|
||||
# Then merge at the project root:
|
||||
graphify merge-graphs \
|
||||
./core/graphify-out/graph.json \
|
||||
./service/graphify-out/graph.json \
|
||||
./platform/graphify-out/graph.json \
|
||||
--out graphify-out/graph.json
|
||||
```
|
||||
|
||||
Once `graphify-out/graph.json` exists, the fast path above takes over: any codebase question runs `graphify query` directly on the merged graph — no re-extraction, no size gate.
|
||||
33
.codex/graphify/references/hooks.md
Normal file
33
.codex/graphify/references/hooks.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# graphify reference: commit hook and native CLAUDE.md integration
|
||||
|
||||
Load this when the user asked to install the post-commit hook or wire graphify into a project's CLAUDE.md.
|
||||
|
||||
## For git commit hook
|
||||
|
||||
Install a post-commit hook that auto-rebuilds the graph after every commit. No background process needed - triggers once per commit, works with any editor.
|
||||
|
||||
```bash
|
||||
graphify hook install # install
|
||||
graphify hook uninstall # remove
|
||||
graphify hook status # check
|
||||
```
|
||||
|
||||
After every `git commit`, the hook detects which code files changed (via `git diff HEAD~1`), re-runs AST extraction on those files, and rebuilds `graph.json` and `GRAPH_REPORT.md`. Doc/image changes are ignored by the hook - run `/graphify --update` manually for those.
|
||||
|
||||
If a post-commit hook already exists, graphify appends to it rather than replacing it.
|
||||
|
||||
---
|
||||
|
||||
## For native CLAUDE.md integration
|
||||
|
||||
Run once per project to make graphify always-on in Claude Code sessions:
|
||||
|
||||
```bash
|
||||
graphify claude install
|
||||
```
|
||||
|
||||
This writes a `## graphify` section to the local `CLAUDE.md` that instructs Claude to check the graph before answering codebase questions and rebuild it after code changes. No manual `/graphify` needed in future sessions.
|
||||
|
||||
```bash
|
||||
graphify claude uninstall # remove the section
|
||||
```
|
||||
311
.codex/graphify/references/query.md
Normal file
311
.codex/graphify/references/query.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# graphify reference: query, path, explain
|
||||
|
||||
Load this when the user asks a question against an existing graph, or runs `/graphify path` or `/graphify explain`. The core's query stub points here for the full traversal flow. These flows use the `graphify query` CLI when it is available and fall back to an inline NetworkX traversal otherwise.
|
||||
|
||||
Two traversal modes - choose based on the question:
|
||||
|
||||
| Mode | Flag | Best for |
|
||||
|------|------|----------|
|
||||
| BFS (default) | _(none)_ | "What is X connected to?" - broad context, nearest neighbors first |
|
||||
| DFS | `--dfs` | "How does X reach Y?" - trace a specific chain or dependency path |
|
||||
|
||||
First check the graph exists:
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
from pathlib import Path
|
||||
if not Path('graphify-out/graph.json').exists():
|
||||
print('ERROR: No graph found. Run /graphify <path> first to build the graph.')
|
||||
raise SystemExit(1)
|
||||
"
|
||||
```
|
||||
If it fails, stop and tell the user to run `/graphify <path>` first.
|
||||
|
||||
### Step 0 — Constrained query expansion (REQUIRED before traversal)
|
||||
|
||||
graphify's `query` CLI matches nodes via case-folded substring + IDF — there is **no stemming, no synonyms, no cross-language match** inside the binary, and the inline fallback below matches the same way. If the user's question uses different language or different domain vocabulary than the graph's labels (user says "обработчик" / graph says "handler"; user says "authentication" / graph says "Guardian"), the literal matcher returns 0 hits and the answer collapses to noise.
|
||||
|
||||
Fix this **without inventing tokens** by expanding the query against the actual graph vocabulary first:
|
||||
|
||||
1. Extract the token vocabulary from node labels:
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json, re
|
||||
from pathlib import Path
|
||||
data = json.loads(Path('graphify-out/graph.json').read_text())
|
||||
vocab = set()
|
||||
for n in data['nodes']:
|
||||
for c in re.findall(r'[^\W\d_]+', n.get('label','') or '', re.UNICODE):
|
||||
parts = re.findall(r'[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+', c) or [c]
|
||||
for p in parts:
|
||||
t = p.lower()
|
||||
if 3 <= len(t) <= 30:
|
||||
vocab.add(t)
|
||||
Path('graphify-out/.vocab.txt').write_text('\n'.join(sorted(vocab)))
|
||||
print(f'vocab: {len(vocab)} tokens')
|
||||
"
|
||||
```
|
||||
|
||||
2. Read `graphify-out/.vocab.txt`. Then for the user's question, select **up to 12 tokens from this exact list** that semantically match the query intent. Hard constraints:
|
||||
- You MUST pick only tokens present in the vocabulary file. Do NOT invent tokens.
|
||||
- If a query concept has no plausible token in the vocab, skip it — do not substitute a near-synonym from training memory.
|
||||
- If **no** vocab tokens match the query at all, output an empty list and tell the user the corpus has no relevant vocabulary for this question. Do not fabricate a search.
|
||||
- Translate cross-language: Russian "аутентификация" → look for `auth`, `credential`, `token`, `security` IFF present in vocab.
|
||||
- Morphology: "handlers" maps to `handler` IFF present; "todos" maps to `todo` IFF present.
|
||||
|
||||
3. Print the selection explicitly to the user before running the query, so the expansion is auditable:
|
||||
```
|
||||
Query expanded to (from graph vocab, N tokens): [token1, token2, ...]
|
||||
```
|
||||
If the list is empty, say so plainly and stop — do not proceed to traversal.
|
||||
|
||||
### Step 1 — Traversal
|
||||
|
||||
Build the **expanded query string** by joining the selected tokens with spaces. Use this string as `QUESTION` below — NOT the original user question. (The original question is preserved only for `save-result` at the end.)
|
||||
|
||||
Prefer the CLI when it is installed:
|
||||
```bash
|
||||
graphify query "QUESTION"
|
||||
# or: graphify query "QUESTION" --dfs --budget 3000
|
||||
```
|
||||
|
||||
If the CLI is unavailable, load `graphify-out/graph.json` and run the traversal inline:
|
||||
|
||||
1. Find the 1-3 nodes whose label best matches the expanded tokens.
|
||||
2. Run the appropriate traversal from each starting node.
|
||||
3. Read the subgraph - node labels, edge relations, confidence tags, source locations.
|
||||
4. Answer using **only** what the graph contains. Quote `source_location` when citing a specific fact.
|
||||
5. If the graph lacks enough information, say so - do not hallucinate edges.
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import sys, json
|
||||
from networkx.readwrite import json_graph
|
||||
import networkx as nx
|
||||
from pathlib import Path
|
||||
|
||||
data = json.loads(Path('graphify-out/graph.json').read_text())
|
||||
G = json_graph.node_link_graph(data, edges='links')
|
||||
|
||||
question = 'QUESTION'
|
||||
mode = 'MODE' # 'bfs' or 'dfs'
|
||||
terms = [t.lower() for t in question.split() if len(t) >= 3] # match the vocab threshold; keeps api/jwt/ios (#1392)
|
||||
|
||||
# Find best-matching start nodes
|
||||
scored = []
|
||||
for nid, ndata in G.nodes(data=True):
|
||||
label = ndata.get('label', '').lower()
|
||||
score = sum(1 for t in terms if t in label)
|
||||
if score > 0:
|
||||
scored.append((score, nid))
|
||||
scored.sort(reverse=True)
|
||||
start_nodes = [nid for _, nid in scored[:3]]
|
||||
|
||||
if not start_nodes:
|
||||
print('No matching nodes found for query terms:', terms)
|
||||
sys.exit(0)
|
||||
|
||||
subgraph_nodes = set()
|
||||
subgraph_edges = []
|
||||
|
||||
if mode == 'dfs':
|
||||
# DFS: follow one path as deep as possible before backtracking.
|
||||
# Depth-limited to 6 to avoid traversing the whole graph.
|
||||
visited = set()
|
||||
stack = [(n, 0) for n in reversed(start_nodes)]
|
||||
while stack:
|
||||
node, depth = stack.pop()
|
||||
if node in visited or depth > 6:
|
||||
continue
|
||||
visited.add(node)
|
||||
subgraph_nodes.add(node)
|
||||
for neighbor in G.neighbors(node):
|
||||
if neighbor not in visited:
|
||||
stack.append((neighbor, depth + 1))
|
||||
subgraph_edges.append((node, neighbor))
|
||||
else:
|
||||
# BFS: explore all neighbors layer by layer up to depth 3.
|
||||
frontier = set(start_nodes)
|
||||
subgraph_nodes = set(start_nodes)
|
||||
for _ in range(3):
|
||||
next_frontier = set()
|
||||
for n in frontier:
|
||||
for neighbor in G.neighbors(n):
|
||||
if neighbor not in subgraph_nodes:
|
||||
next_frontier.add(neighbor)
|
||||
subgraph_edges.append((n, neighbor))
|
||||
subgraph_nodes.update(next_frontier)
|
||||
frontier = next_frontier
|
||||
|
||||
# Token-budget aware output: rank by relevance, cut at budget (~4 chars/token)
|
||||
token_budget = BUDGET # default 2000
|
||||
char_budget = token_budget * 4
|
||||
|
||||
# Score each node by term overlap for ranked output
|
||||
def relevance(nid):
|
||||
label = G.nodes[nid].get('label', '').lower()
|
||||
return sum(1 for t in terms if t in label)
|
||||
|
||||
ranked_nodes = sorted(subgraph_nodes, key=relevance, reverse=True)
|
||||
|
||||
lines = [f'Traversal: {mode.upper()} | Start: {[G.nodes[n].get(\"label\",n) for n in start_nodes]} | {len(subgraph_nodes)} nodes']
|
||||
for nid in ranked_nodes:
|
||||
d = G.nodes[nid]
|
||||
lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]')
|
||||
for u, v in subgraph_edges:
|
||||
if u in subgraph_nodes and v in subgraph_nodes:
|
||||
_raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}')
|
||||
|
||||
output = '\n'.join(lines)
|
||||
if len(output) > char_budget:
|
||||
output = output[:char_budget] + f'\n... (truncated at ~{token_budget} token budget - use --budget N for more)'
|
||||
print(output)
|
||||
"
|
||||
```
|
||||
|
||||
Replace `QUESTION` with the **expanded** query string, `MODE` with `bfs` or `dfs`, and `BUDGET` with the token budget (default `2000`, or whatever `--budget N` specifies). Then answer based on the subgraph output above, using only what the graph contains.
|
||||
|
||||
After writing the answer, save it back into the graph so it improves future queries. Include the expanded tokens inside the `--answer` text (e.g. `"Expanded from original query via vocab: [tokens]. Then traversed..."`) so the next `--update` extracts the expansion history as a graph node:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -m graphify save-result --question "ORIGINAL_QUESTION" --answer "ANSWER" --type query --nodes NODE1 NODE2
|
||||
```
|
||||
|
||||
Replace `ORIGINAL_QUESTION` with the user's verbatim question, `ANSWER` with your full answer text (containing the expanded-token trace), `NODE1 NODE2` with the list of node labels you cited. This closes the feedback loop: the next `--update` will extract this Q&A as a node in the graph.
|
||||
|
||||
**Work memory (self-improving loop).** Add an `--outcome` so future sessions learn from this one — append `--outcome useful|dead_end|corrected` to the `save-result` command (and `--correction "the right answer"` when correcting):
|
||||
|
||||
- `useful` — the cited nodes answered the question well (they become *preferred sources*).
|
||||
- `dead_end` — the question/path led nowhere; don't re-derive it next time.
|
||||
- `corrected` — the saved answer was wrong; `--correction` records what was right.
|
||||
|
||||
At the **start** of graph work, refresh and read the lessons: run `graphify reflect --if-stale` (cheap, deterministic, no LLM; `--if-stale` makes it a no-op when `LESSONS.md` is already newer than every input, e.g. when the git hook just refreshed it), then read `graphify-out/reflections/LESSONS.md`. It lists **preferred sources** (start there), **known dead ends** (skip them), and prior **corrections**. Running `reflect` yourself keeps the lessons current even without the git hook installed; if the post-commit hook *is* installed, `--if-stale` means your session-start run costs almost nothing.
|
||||
|
||||
---
|
||||
|
||||
## For /graphify path
|
||||
|
||||
Find the shortest path between two named concepts in the graph. Prefer the CLI when installed:
|
||||
|
||||
```bash
|
||||
graphify path "NODE_A" "NODE_B"
|
||||
```
|
||||
|
||||
If the CLI is unavailable, run it inline:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json, sys
|
||||
import networkx as nx
|
||||
from networkx.readwrite import json_graph
|
||||
from pathlib import Path
|
||||
|
||||
data = json.loads(Path('graphify-out/graph.json').read_text())
|
||||
G = json_graph.node_link_graph(data, edges='links')
|
||||
|
||||
a_term = 'NODE_A'
|
||||
b_term = 'NODE_B'
|
||||
|
||||
def find_node(term):
|
||||
term = term.lower()
|
||||
scored = sorted(
|
||||
[(sum(1 for w in term.split() if w in G.nodes[n].get('label','').lower()), n)
|
||||
for n in G.nodes()],
|
||||
reverse=True
|
||||
)
|
||||
return scored[0][1] if scored and scored[0][0] > 0 else None
|
||||
|
||||
src = find_node(a_term)
|
||||
tgt = find_node(b_term)
|
||||
|
||||
if not src or not tgt:
|
||||
print(f'Could not find nodes matching: {a_term!r} or {b_term!r}')
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
path = nx.shortest_path(G, src, tgt)
|
||||
print(f'Shortest path ({len(path)-1} hops):')
|
||||
for i, nid in enumerate(path):
|
||||
label = G.nodes[nid].get('label', nid)
|
||||
if i < len(path) - 1:
|
||||
_raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
print(f' {label} --{rel}--> [{conf}]')
|
||||
else:
|
||||
print(f' {label}')
|
||||
except nx.NetworkXNoPath:
|
||||
print(f'No path found between {a_term!r} and {b_term!r}')
|
||||
except nx.NodeNotFound as e:
|
||||
print(f'Node not found: {e}')
|
||||
"
|
||||
```
|
||||
|
||||
Replace `NODE_A` and `NODE_B` with the actual concept names from the user. Then explain the path in plain language - what each hop means, why it's significant.
|
||||
|
||||
After writing the explanation, save it back:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -m graphify save-result --question "Path from NODE_A to NODE_B" --answer "ANSWER" --type path_query --nodes NODE_A NODE_B
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## For /graphify explain
|
||||
|
||||
Give a plain-language explanation of a single node - everything connected to it. Prefer the CLI when installed:
|
||||
|
||||
```bash
|
||||
graphify explain "NODE_NAME"
|
||||
```
|
||||
|
||||
If the CLI is unavailable, run it inline:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json, sys
|
||||
import networkx as nx
|
||||
from networkx.readwrite import json_graph
|
||||
from pathlib import Path
|
||||
|
||||
data = json.loads(Path('graphify-out/graph.json').read_text())
|
||||
G = json_graph.node_link_graph(data, edges='links')
|
||||
|
||||
term = 'NODE_NAME'
|
||||
term_lower = term.lower()
|
||||
|
||||
# Find best matching node
|
||||
scored = sorted(
|
||||
[(sum(1 for w in term_lower.split() if w in G.nodes[n].get('label','').lower()), n)
|
||||
for n in G.nodes()],
|
||||
reverse=True
|
||||
)
|
||||
if not scored or scored[0][0] == 0:
|
||||
print(f'No node matching {term!r}')
|
||||
sys.exit(0)
|
||||
|
||||
nid = scored[0][1]
|
||||
data_n = G.nodes[nid]
|
||||
print(f'NODE: {data_n.get(\"label\", nid)}')
|
||||
print(f' source: {data_n.get(\"source_file\",\"unknown\")}')
|
||||
print(f' type: {data_n.get(\"file_type\",\"unknown\")}')
|
||||
print(f' degree: {G.degree(nid)}')
|
||||
print()
|
||||
print('CONNECTIONS:')
|
||||
for neighbor in G.neighbors(nid):
|
||||
_raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
nlabel = G.nodes[neighbor].get('label', neighbor)
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
src_file = G.nodes[neighbor].get('source_file', '')
|
||||
print(f' --{rel}--> {nlabel} [{conf}] ({src_file})')
|
||||
"
|
||||
```
|
||||
|
||||
Replace `NODE_NAME` with the concept the user asked about. Then write a 3-5 sentence explanation of what this node is, what it connects to, and why those connections are significant. Use the source locations as citations.
|
||||
|
||||
After writing the explanation, save it back:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -m graphify save-result --question "Explain NODE_NAME" --answer "ANSWER" --type explain --nodes NODE_NAME
|
||||
```
|
||||
52
.codex/graphify/references/transcribe.md
Normal file
52
.codex/graphify/references/transcribe.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# graphify reference: transcribe video and audio
|
||||
|
||||
Load this only when `detect` reported one or more `video` files. A corpus with no video never reads this.
|
||||
|
||||
### Step 2.5 - Transcribe video / audio files (only if video files detected)
|
||||
|
||||
Skip this step entirely if `detect` returned zero `video` files.
|
||||
|
||||
Video and audio files cannot be read directly. Transcribe them to text first, then treat the transcripts as doc files in Step 3.
|
||||
|
||||
**Strategy:** Read the god nodes from `graphify-out/.graphify_detect.json` (or the analysis file if it exists from a previous run). You are already a language model — write a one-sentence domain hint yourself from those labels. Then pass it to Whisper as the initial prompt. No separate API call needed.
|
||||
|
||||
**However**, if the corpus has *only* video files and no other docs/code, use the generic fallback prompt: `"Use proper punctuation and paragraph breaks."`
|
||||
|
||||
**Step 1 - Write the Whisper prompt yourself.**
|
||||
|
||||
Read the top god node labels from detect output or analysis, then compose a short domain hint sentence, for example:
|
||||
|
||||
- Labels: `transformer, attention, encoder, decoder` → `"Machine learning research on transformer architectures and attention mechanisms. Use proper punctuation and paragraph breaks."`
|
||||
- Labels: `kubernetes, deployment, pod, helm` → `"DevOps discussion about Kubernetes deployments and Helm charts. Use proper punctuation and paragraph breaks."`
|
||||
|
||||
**Export** it as `GRAPHIFY_WHISPER_PROMPT` (the exact name the transcriber reads — and it must be `export`ed so the child Python process sees it) for the next command.
|
||||
|
||||
**Step 2 - Transcribe:**
|
||||
|
||||
```bash
|
||||
export GRAPHIFY_WHISPER_MODEL=base # or whatever --whisper-model the user passed (must be exported)
|
||||
export GRAPHIFY_WHISPER_PROMPT="<the one-sentence domain hint you composed in Step 1>"
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json, os, sys
|
||||
from pathlib import Path
|
||||
from graphify.transcribe import transcribe_all
|
||||
|
||||
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
|
||||
video_files = detect.get('files', {}).get('video', [])
|
||||
prompt = os.environ.get('GRAPHIFY_WHISPER_PROMPT', 'Use proper punctuation and paragraph breaks.')
|
||||
|
||||
transcript_paths = transcribe_all(video_files, initial_prompt=prompt)
|
||||
# Write the JSON from Python (NOT a shell '>' redirect): transcribe_all/Whisper
|
||||
# print progress to stdout, which would otherwise corrupt the JSON file (#1392).
|
||||
Path('graphify-out/.graphify_transcripts.json').write_text(json.dumps(transcript_paths, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print(f'Transcribed {len(transcript_paths)} file(s)', file=sys.stderr)
|
||||
"
|
||||
```
|
||||
|
||||
After transcription:
|
||||
- Read the transcript paths from `graphify-out/.graphify_transcripts.json`
|
||||
- Add them to the docs list before dispatching semantic subagents in Step 3B
|
||||
- Print how many transcripts were created: `Transcribed N video file(s) -> treating as docs`
|
||||
- If transcription fails for a file, print a warning and continue with the rest
|
||||
|
||||
**Whisper model:** Default is `base`. If the user passed `--whisper-model <name>`, `export GRAPHIFY_WHISPER_MODEL=<name>` (it must be exported, not just assigned) before running the command above.
|
||||
192
.codex/graphify/references/update.md
Normal file
192
.codex/graphify/references/update.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# graphify reference: incremental update and cluster-only
|
||||
|
||||
Load this only when the user passed `--update` or `--cluster-only`. A first-time full build never reads this file.
|
||||
|
||||
## For --update (incremental re-extraction)
|
||||
|
||||
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import sys, json
|
||||
from graphify.detect import detect_incremental, save_manifest
|
||||
from pathlib import Path
|
||||
|
||||
result = detect_incremental(Path('INPUT_PATH'))
|
||||
new_total = result.get('new_total', 0)
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
Path('graphify-out/.graphify_incremental.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\")
|
||||
deleted = list(result.get('deleted_files', []))
|
||||
if new_total == 0 and not deleted:
|
||||
print('No files changed since last run. Nothing to update.')
|
||||
raise SystemExit(0)
|
||||
if deleted:
|
||||
print(f'{len(deleted)} deleted file(s) to prune.')
|
||||
if new_total > 0:
|
||||
print(f'{new_total} new/changed file(s) to re-extract.')
|
||||
"
|
||||
```
|
||||
|
||||
Then populate `.graphify_detect.json` so Steps 3A–6 (which read it unconditionally) see the right state for an incremental run. `files` carries the changed subset (drives Step 3A AST + Step 3B0 cache check on only what changed); `all_files` carries the full corpus for any step that needs corpus-wide context:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
r = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
|
||||
Path('graphify-out/.graphify_detect.json').write_text(json.dumps({
|
||||
'files': r.get('new_files', {}),
|
||||
'all_files': r.get('files', {}),
|
||||
'total_files': r.get('new_total', 0),
|
||||
'total_words': r.get('total_words', 0),
|
||||
'skipped_sensitive': r.get('skipped_sensitive', []),
|
||||
'needs_graph': True,
|
||||
}, ensure_ascii=False), encoding=\"utf-8\")
|
||||
"
|
||||
```
|
||||
|
||||
If new files exist, first check whether all changed files are code files:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {}
|
||||
code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'}
|
||||
new_files = result.get('new_files', {})
|
||||
all_changed = [f for files in new_files.values() for f in files]
|
||||
code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed)
|
||||
print('code_only:', code_only)
|
||||
"
|
||||
```
|
||||
|
||||
If `code_only` is True: print `[graphify update] Code-only changes detected - skipping semantic extraction (no LLM needed)`, run only Step 3A (AST) on the changed files, skip Step 3B entirely (no subagents), then go straight to merge and Steps 4–8.
|
||||
|
||||
If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal.
|
||||
|
||||
|
||||
If no new files exist (only deletions), create an empty extraction so the merge step can prune:
|
||||
|
||||
```bash
|
||||
if [ ! -f graphify-out/.graphify_extract.json ]; then
|
||||
echo '[graphify update] Only deletions -- creating empty extraction for merge.'
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
Path('graphify-out/.graphify_extract.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
|
||||
"
|
||||
fi
|
||||
```
|
||||
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
from graphify.build import build_merge
|
||||
from graphify.detect import save_manifest
|
||||
|
||||
# Load new extraction and incremental state
|
||||
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
|
||||
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
|
||||
deleted = list(incremental.get('deleted_files', []))
|
||||
# prune_sources is ONLY for genuinely DELETED files. Changed/re-extracted files are
|
||||
# handled by build_merge's replace-on-re-extract (#1344): every source_file in
|
||||
# new_chunks is dropped from the base before merge, so old/stale nodes don't survive.
|
||||
# Do NOT add `changed` here: with root= passed, prune_set relativizes to the same base
|
||||
# as the freshly merged nodes and would DELETE the re-extracted content (#1178 is moot
|
||||
# now that replace — not the dedup pass — reconciles changed files).
|
||||
prune = list(deleted) or None
|
||||
|
||||
# Use build_merge() — reads graph.json directly without NetworkX round-trip
|
||||
# so edge direction (calls, implements, imports) is always preserved (#801).
|
||||
# Pass root= so prune_sources (absolute paths from detect_incremental) are
|
||||
# relativized to match the graph's relative source_file values; without it
|
||||
# nothing is pruned and stale nodes accumulate on every update (#1361).
|
||||
# directed=IS_DIRECTED: replace IS_DIRECTED with True if --directed was given, else
|
||||
# False. Without it a --directed --update silently rebuilds undirected and collapses
|
||||
# reciprocal A<->B edges (#1392).
|
||||
G = build_merge(
|
||||
[new_extraction],
|
||||
graph_path='graphify-out/graph.json',
|
||||
prune_sources=prune,
|
||||
root='INPUT_PATH',
|
||||
directed=IS_DIRECTED,
|
||||
)
|
||||
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
|
||||
|
||||
# Write merged result back to .graphify_extract.json so Step 4 sees the full graph
|
||||
merged_out = {
|
||||
'nodes': [{'id': n, **d} for n, d in G.nodes(data=True)],
|
||||
'edges': [
|
||||
# Explicit source/target last so they win over any stale attrs in d.
|
||||
{**{k: val for k, val in d.items() if k not in ('_src', '_tgt', 'source', 'target')},
|
||||
'source': d.get('_src', u), 'target': d.get('_tgt', v)}
|
||||
for u, v, d in G.edges(data=True)
|
||||
],
|
||||
# G.graph["hyperedges"] holds hyperedges from both existing graph.json
|
||||
# and new_extraction (build_merge combines them). Falling back to
|
||||
# new_extraction only would silently drop prior-run hyperedges (#801).
|
||||
'hyperedges': list(G.graph.get('hyperedges', [])),
|
||||
'input_tokens': new_extraction.get('input_tokens', 0),
|
||||
'output_tokens': new_extraction.get('output_tokens', 0),
|
||||
}
|
||||
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged_out, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print(f'[graphify update] Merged extraction written ({len(merged_out[\"nodes\"])} nodes, {len(merged_out[\"edges\"])} edges)')
|
||||
|
||||
# Save manifest so next --update diffs against today's state, not the
|
||||
# prior run's baseline (prevents ghost-node reports on subsequent updates).
|
||||
# root= matches the build_merge call above so the manifest keys stay relative to
|
||||
# the scan root — portable across clones/machines, so --update keeps matching
|
||||
# cached files instead of missing every one after a move (#1417).
|
||||
save_manifest(incremental['files'], root='INPUT_PATH')
|
||||
print('[graphify update] Manifest saved.')
|
||||
"
|
||||
```
|
||||
|
||||
Then run Steps 4–8 on the merged graph as normal.
|
||||
|
||||
After Step 4, show the graph diff:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from graphify.analyze import graph_diff
|
||||
from graphify.build import build_from_json
|
||||
from networkx.readwrite import json_graph
|
||||
import networkx as nx
|
||||
from pathlib import Path
|
||||
|
||||
# Load old graph (before update) from backup written before merge
|
||||
old_data = json.loads(Path('graphify-out/.graphify_old.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_old.json').exists() else None
|
||||
new_extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
|
||||
G_new = build_from_json(new_extract, directed=IS_DIRECTED)
|
||||
|
||||
if old_data:
|
||||
G_old = json_graph.node_link_graph(old_data, edges='links')
|
||||
diff = graph_diff(G_old, G_new)
|
||||
print(diff['summary'])
|
||||
if diff['new_nodes']:
|
||||
print('New nodes:', ', '.join(n['label'] for n in diff['new_nodes'][:5]))
|
||||
if diff['new_edges']:
|
||||
print('New edges:', len(diff['new_edges']))
|
||||
"
|
||||
```
|
||||
|
||||
Before the merge step, save the old graph: `cp graphify-out/graph.json graphify-out/.graphify_old.json`
|
||||
Clean up after: `rm -f graphify-out/.graphify_old.json`
|
||||
|
||||
---
|
||||
|
||||
## For --cluster-only
|
||||
|
||||
Skip Steps 1–3. Re-run clustering on the existing graph:
|
||||
|
||||
```bash
|
||||
graphify cluster-only .
|
||||
```
|
||||
|
||||
`graphify cluster-only .` is **self-contained**: it re-clusters, names communities, and regenerates `GRAPH_REPORT.md`, `graph.json`, and `graph.html` from the existing graph. **Do not re-run Steps 5–9** — they read intermediate files (`.graphify_extract.json`, `.graphify_detect.json`, `.graphify_analysis.json`) that a prior build's cleanup (Step 9) already deleted, so they raise `FileNotFoundError` (#1392). When it finishes, present the refreshed `GRAPH_REPORT.md` summary as usual.
|
||||
226
.gitea/workflows/docker-build.yaml
Executable file → Normal file
226
.gitea/workflows/docker-build.yaml
Executable file → Normal file
@@ -1,4 +1,4 @@
|
||||
name: Build and Push Docker Images
|
||||
name: Проверка, сборка и развёртывание Magistr
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -7,91 +7,201 @@ on:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
concurrency:
|
||||
group: magistr-production
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.zuev.company # Замените на реальный домен вашего Gitea
|
||||
REGISTRY: gitea.zuev.company
|
||||
BACKEND_IMAGE: zuev/magistr-backend
|
||||
FRONTEND_IMAGE: zuev/magistr-frontend
|
||||
MAVEN_IMAGE: maven:3.9.9-eclipse-temurin-17@sha256:f58d59b6273e785ac0a4477f6e9b5ba1d7731c75b906c0f7b34076f1851318cc
|
||||
NODE_IMAGE: node:22-alpine3.23@sha256:8516dce0483394d5708d4b2ee6cacb79fb1d617ea4e2787c2120bcca92ce372e
|
||||
TRIVY_IMAGE: aquasec/trivy:0.63.0@sha256:6fb0646988fcd2fdf7bf123f7174945ebc2c9c72d1fa1567c8d7daeeb70f8037
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
name: Обязательные проверки
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Получить исходный код
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Backend unit и component tests
|
||||
run: >-
|
||||
docker run --rm
|
||||
-v "${{ github.workspace }}/backend:/workspace"
|
||||
-w /workspace
|
||||
"${{ env.MAVEN_IMAGE }}"
|
||||
mvn -Dapi.version=1.44 '-Dtest=!*IntegrationTest' test
|
||||
|
||||
- name: Frontend static и unit tests
|
||||
run: >-
|
||||
docker run --rm
|
||||
-v "${{ github.workspace }}/frontend:/workspace"
|
||||
-w /workspace
|
||||
"${{ env.NODE_IMAGE }}"
|
||||
sh -c "npm ci && npm run check"
|
||||
|
||||
- name: Проверить Docker Compose
|
||||
env:
|
||||
POSTGRES_PASSWORD: ci-config-only
|
||||
JWT_SECRET: ci-only-secret-not-for-runtime-0123456789abcdef0123456789abcdef
|
||||
run: docker compose config --quiet
|
||||
|
||||
- name: Проверить сценарий immutable rollout и rollback
|
||||
run: bash scripts/test-deploy-images.sh
|
||||
|
||||
- name: Проверить закрепление артефактов и supply-chain gates
|
||||
run: bash scripts/test-artifact-pinning.sh
|
||||
|
||||
build-and-push-backend:
|
||||
name: Собрать backend image
|
||||
needs: checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Получить исходный код
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.ZUEV_TOKEN }} # Нужно создать секрет ZUEV_TOKEN в настройках репозитория (Personal Access Token)
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./backend
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: |
|
||||
${{ steps.meta.outputs.labels }}
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
build-and-push-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
- name: Войти в Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.ZUEV_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
- name: Сформировать immutable tags и labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}
|
||||
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}
|
||||
flavor: latest=false
|
||||
tags: |
|
||||
type=sha,format=long,prefix=sha-
|
||||
type=ref,event=tag
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
- name: Собрать и опубликовать backend image
|
||||
id: build
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: ./frontend
|
||||
context: ./backend
|
||||
push: true
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: |
|
||||
${{ steps.meta.outputs.labels }}
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
|
||||
deploy-to-k8s:
|
||||
build-and-push-frontend:
|
||||
name: Собрать frontend image
|
||||
needs: checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- name: Получить исходный код
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Войти в Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.ZUEV_TOKEN }}
|
||||
|
||||
- name: Сформировать immutable tags и labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}
|
||||
flavor: latest=false
|
||||
tags: |
|
||||
type=sha,format=long,prefix=sha-
|
||||
type=ref,event=tag
|
||||
|
||||
- name: Собрать и опубликовать frontend image
|
||||
id: build
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: ./frontend
|
||||
push: true
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: |
|
||||
${{ steps.meta.outputs.labels }}
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
|
||||
security-scan:
|
||||
name: Проверить опубликованные образы
|
||||
needs: [build-and-push-backend, build-and-push-frontend]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
BACKEND_REF: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}@${{ needs.build-and-push-backend.outputs.digest }}
|
||||
FRONTEND_REF: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}@${{ needs.build-and-push-frontend.outputs.digest }}
|
||||
steps:
|
||||
- name: Create kubeconfig
|
||||
- name: Войти в Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.ZUEV_TOKEN }}
|
||||
|
||||
- name: Проверить backend на HIGH и CRITICAL уязвимости
|
||||
run: >-
|
||||
docker run --rm
|
||||
-v "$HOME/.docker:/root/.docker:ro"
|
||||
"${{ env.TRIVY_IMAGE }}"
|
||||
image --scanners vuln --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed
|
||||
"$BACKEND_REF"
|
||||
|
||||
- name: Проверить frontend на HIGH и CRITICAL уязвимости
|
||||
run: >-
|
||||
docker run --rm
|
||||
-v "$HOME/.docker:/root/.docker:ro"
|
||||
"${{ env.TRIVY_IMAGE }}"
|
||||
image --scanners vuln --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed
|
||||
"$FRONTEND_REF"
|
||||
|
||||
deploy-to-k8s:
|
||||
name: Развернуть проверенные digests
|
||||
needs: [build-and-push-backend, build-and-push-frontend, security-scan]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
environment: production
|
||||
env:
|
||||
BACKEND_REF: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}@${{ needs.build-and-push-backend.outputs.digest }}
|
||||
FRONTEND_REF: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}@${{ needs.build-and-push-frontend.outputs.digest }}
|
||||
KUBECTL_VERSION: v1.33.12
|
||||
KUBECTL_SHA256: fe80ae4133b44fa2077db4af144e80765eb1b3b2eede55fbff6933c4374d8c6e
|
||||
steps:
|
||||
- name: Получить deploy-скрипт
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Создать kubeconfig
|
||||
env:
|
||||
KUBECONFIG_DATA: ${{ secrets.KUBECONFIG_DATA }}
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
install -d -m 700 "$HOME/.kube"
|
||||
printf '%s' "$KUBECONFIG_DATA" | base64 --decode > "$HOME/.kube/config"
|
||||
chmod 600 "$HOME/.kube/config"
|
||||
|
||||
- name: Install kubectl
|
||||
- name: Установить проверенный kubectl
|
||||
run: |
|
||||
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
||||
chmod +x kubectl
|
||||
mv kubectl /usr/local/bin/
|
||||
|
||||
- name: Trigger Kubernetes Rollout
|
||||
run: |
|
||||
# Перезапускаем поды, чтобы они скачали свежий :main образ
|
||||
kubectl rollout restart deployment backend frontend -n magistr
|
||||
|
||||
# Ждём успешного обновления (5 минут на backend из-за Spring Boot)
|
||||
kubectl rollout status deployment/frontend -n magistr --timeout=120s
|
||||
kubectl rollout status deployment/backend -n magistr --timeout=300s
|
||||
curl --fail --show-error --silent --location --retry 3 \
|
||||
--output kubectl \
|
||||
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
|
||||
echo "${KUBECTL_SHA256} kubectl" | sha256sum --check --strict
|
||||
sudo install -m 0755 kubectl /usr/local/bin/kubectl
|
||||
|
||||
- name: Применить digests и проверить rollout
|
||||
run: bash scripts/deploy-images.sh "$BACKEND_REF" "$FRONTEND_REF"
|
||||
|
||||
1067
BUG_FIX_PROGRESS.md
Normal file
1067
BUG_FIX_PROGRESS.md
Normal file
File diff suppressed because it is too large
Load Diff
193
BUG_FIX_PROMPT.md
Normal file
193
BUG_FIX_PROMPT.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Промпт для исправления ошибок из `BUG_REPORT.md`
|
||||
|
||||
Ты работаешь как ведущий full-stack инженер, специалист по безопасности и DevOps в репозитории системы университетского расписания Magistr.
|
||||
|
||||
Твоя задача — не просто составить план, а последовательно реализовать, протестировать и документировать исправления всех проблем из корневого файла `BUG_REPORT.md`, **кроме проблемы № 2**.
|
||||
|
||||
## Обязательная область работ
|
||||
|
||||
Исправь проблемы **№ 1 и № 3–34**. Проблема **№ 2 полностью исключена из задачи**:
|
||||
|
||||
`[1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34]`
|
||||
|
||||
Это ровно 33 проблемы: одна критическая № 1, десять высокого приоритета № 3–12, девятнадцать среднего приоритета № 13–31 и три низкого приоритета № 32–34.
|
||||
|
||||
- не исправляй её;
|
||||
- не изменяй ради неё демонстрационные данные или учётные записи;
|
||||
- не добавляй миграцию для отключения demo-аккаунтов;
|
||||
- не меняй существующие Flyway-миграции, включая `V1__init.sql` и `V2__subgroups_active_unique_name.sql`;
|
||||
- не включай проблему № 2 в итоговый список выполненных исправлений.
|
||||
|
||||
Если изменение для другого пункта пересекается с проблемой № 2, реализуй только часть, необходимую для другого пункта, и не затрагивай seed/demo-данные.
|
||||
|
||||
## Проектный контекст и обязательные правила
|
||||
|
||||
1. Сначала полностью прочитай:
|
||||
- корневой `AGENTS.md` — это главный проектный регламент;
|
||||
- `BUG_REPORT.md`;
|
||||
- относящиеся к изменениям документы из `docs/`;
|
||||
- фактический код и тесты в затронутых подсистемах;
|
||||
- production-манифесты в `../k8s/`, которые входят в область проекта согласно `AGENTS.md`.
|
||||
2. При конфликте любых инструкций проекта с `AGENTS.md` следуй `AGENTS.md`.
|
||||
3. Используй проектные навыки из `.agents/skills`, когда задача соответствует их описанию. Для навигации по проекту сначала используй существующий `graphify-out/graph.json`, если он актуален. После изменений в коде примени `AutoUpdateDocs`.
|
||||
4. Соблюдай стек проекта: Java 17, Spring Boot 3.2.5, PostgreSQL/Flyway, Vanilla JavaScript, HTML и CSS без frontend-фреймворков.
|
||||
5. Все ответы, комментарии, UI-тексты, пользовательские ошибки и проектные логи должны быть на русском языке. Технические идентификаторы допустимы в структурированных полях логов.
|
||||
6. Сохраняй мультитенантную модель: отдельная PostgreSQL БД для каждого tenant. Проверяй, что миграции и фоновые задачи корректно работают для всех tenant-БД.
|
||||
7. Не изменяй существующие Flyway-миграции. Любые новые ограничения схемы оформляй новыми последовательно пронумерованными миграциями, начиная со следующей свободной версии. Сначала проверь фактическую максимальную версию в `backend/src/main/resources/db/migration/`.
|
||||
8. Сохраняй уже имеющиеся пользовательские изменения в рабочем дереве. Не откатывай и не перезаписывай несвязанные правки.
|
||||
9. Не выполняй `commit`, `push`, deployment, ротацию production-секретов, переписывание истории Git или иные внешние необратимые действия без отдельного разрешения. Репозиторную часть исправлений реализуй полностью, а необходимые production-действия оформи отдельным точным runbook.
|
||||
10. Не придумывай и не коммить реальные секреты. Не выводи секреты, пароли, токены, cookie или строки подключения в ответах, тестах и логах.
|
||||
11. Не скрывай ошибки пустыми `catch`, не ослабляй проверки безопасности ради прохождения тестов и не отмечай проблему исправленной только за счёт комментария или документации.
|
||||
12. Если замечание из отчёта уже исправлено в текущем коде, проверь это по реализации, добавь или уточни регрессионный тест и укажи доказательство. Не делай лишнюю повторную правку.
|
||||
13. Не редактируй `BUG_REPORT.md`: он остаётся исходным реестром требований и основанием для итоговой проверки.
|
||||
14. Учитывай, что `../k8s/` находится вне Git-корня `magistr`: отдельно перечисляй и проверяй каждый изменённый там файл. `git diff` текущего репозитория не подтверждает состояние `../k8s/`.
|
||||
|
||||
## Режим выполнения
|
||||
|
||||
Задача большая, поэтому веди явный чек-лист по номерам проблем: `1, 3, 4, …, 34`. Для каждого пункта зафиксируй:
|
||||
|
||||
- подтверждённую первопричину;
|
||||
- изменяемые файлы и выбранное решение;
|
||||
- автоматические тесты или иную воспроизводимую проверку;
|
||||
- статус: `не начато`, `в работе`, `исправлено и проверено` либо `требуется внешнее действие`.
|
||||
|
||||
Раздели работу на независимые потоки и используй субагентов, если платформа это поддерживает, но координируй общие файлы, номера Flyway-миграций, API-контракты и role matrix централизованно. Не останавливайся после анализа или первой группы исправлений. Продолжай, пока все пункты из области работ не будут реализованы и проверены либо пока не останется объективный блокер, требующий внешних полномочий.
|
||||
|
||||
Перед редактированием:
|
||||
|
||||
1. Выполни `git status --short` и сохрани понимание исходных пользовательских изменений.
|
||||
2. Зафиксируй список и контрольные суммы всех существующих Flyway-миграций, чтобы доказать их неизменность в финале.
|
||||
3. Проверь каждое утверждение отчёта по актуальному коду.
|
||||
4. Составь краткий порядок правок с учётом зависимостей.
|
||||
5. Запусти доступный базовый набор тестов, чтобы отличать исходные сбои от внесённых регрессий.
|
||||
|
||||
## Требуемый результат по каждому пункту
|
||||
|
||||
### Безопасность и аутентификация
|
||||
|
||||
- **№ 1 — секреты в production-конфигурации.** Удали фиксированные, дефолтные и placeholder-секреты из отслеживаемой конфигурации; перенеси чувствительные значения из ConfigMap в подходящий механизм Secret/External Secrets/SOPS/Sealed Secrets либо подготовь безопасные ссылки на секреты без самих значений. На production-профиле приложение должно завершать запуск при отсутствующем, дефолтном или известном placeholder `JWT_SECRET`, а также при небезопасном refresh-cookie. Не переписывай историю Git и не ротируй реальные секреты самостоятельно — подготовь отдельный runbook ротации и очистки истории. При secret scan не трактуй неизменяемые demo-значения из `V1__init.sql`, относящиеся к исключённой проблеме № 2, как разрешение менять V1 или решать пункт № 2.
|
||||
- **№ 3 — конкурентная ротация refresh-токена.** Обеспечь атомарную single-use семантику через блокировку строки или условный `UPDATE`; только один из параллельных запросов может создать следующий токен. Добавь конкурентный интеграционный тест с реальным PostgreSQL/Testcontainers.
|
||||
- **№ 4 — утечка `AuthContext`.** Очищай контекст в начале обработки и на всех отказах, а пользователя устанавливай только после успешной аутентификации и авторизации. Добавь тест последовательных запросов на одном servlet-потоке.
|
||||
- **№ 12 — access JWT и удалённый JavaScript в одном origin.** Убери исполнение неприкреплённых удалённых модулей, зафиксируй зависимости lockfile и раздавай их локально. Убери долговременное хранение access token в `localStorage` и `sessionStorage`: выбери и последовательно реализуй безопасную схему с access token в памяти и HttpOnly refresh-cookie либо полноценной защищённой cookie-сессией. Добавь строгий CSP без `unsafe-inline`/`unsafe-eval`, устрани несовместимые inline-скрипты и проверь все сценарии входа, refresh, logout и безопасного восстановления сессии после перезагрузки страницы.
|
||||
- **№ 14 — отключённая TLS-проверка Kubernetes API.** Удали доверие любому сертификату. Используй service-account CA `/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`, стандартную проверку цепочки и hostname; ошибки должны быть явными и безопасными.
|
||||
- **№ 24 — бесконечный рост refresh-токенов.** Добавь tenant-aware cleanup с настраиваемым сроком хранения отозванных/истёкших записей, безопасным расписанием и тестами. Очистка должна сохранять активные и свежие audit-записи, быть идемпотентной и безопасной при двух pod.
|
||||
- **№ 25 — утечка DB/JDBC-ошибок.** Централизованно преобразуй `DataIntegrityViolationException` и известные constraints в корректные `400`/`409` с русскими сообщениями. Внутренние тексты исключений не должны попадать клиенту.
|
||||
- **№ 28 — отсутствие ограничения входа.** Реализуй общий для нескольких backend-pod rate limit по tenant + IP + нормализованному username, прогрессивную задержку или временную блокировку и аудит неудачных попыток без логирования пароля. Учти корректное определение клиентского IP только за доверенным proxy, не допускай user enumeration, возвращай `429 Too Many Requests` и корректный `Retry-After`. Не выдавай локальный in-memory limiter одного pod за production-решение.
|
||||
- **№ 33 — DOM XSS и видимые пароли.** Замени небезопасные вставки ошибок через `innerHTML` на `textContent` или проверенное экранирование. Поля пароля должны иметь `type="password"` и корректные `autocomplete`.
|
||||
- **№ 34 — языковой регламент.** Приведи затронутые UI-тексты, ошибки и production-логи к русскому языку, не отдавая пользователю сырой JDBC/HTTP exception text.
|
||||
|
||||
### Расписание, календарь и бизнес-инварианты
|
||||
|
||||
- **№ 5 — невалидные schedule overrides.** До сохранения построй фактическую исходную пару на дату, проверь обязательные поля для каждого action, существование пары и конфликты преподавателя, аудитории и всех групп в результирующем расписании. Конфликт должен давать `409 Conflict` с русским сообщением.
|
||||
- **№ 6 — замена преподавателя не видна в его расписании.** Учитывай overrides с `newTeacher` за период, достраивай относящиеся базовые пары и выполняй окончательную фильтрацию после применения overrides. Покрой исходного и нового преподавателя тестами.
|
||||
- **№ 7 — неполная валидация правил.** Проверяй конфликты и дубли внутри нового правила, роль `TEACHER`, допустимый enum формата и чётное положительное количество академических часов. Где уместно, продублируй инварианты новой Flyway-миграцией.
|
||||
- **№ 8 — частичный commit календарной сетки.** Сначала валидируй весь payload и уникальность ключей, затем атомарно заменяй сетку в транзакции. После любого `400` прежние данные должны остаться без изменений.
|
||||
- **№ 11 — ложный зелёный статус дашборда.** Рассчитывай воскресенье как отдельную дату `понедельник + 6 дней`, не используй UTC для date-only значений и разделяй состояния «конфликтов нет», «частичная ошибка проверки» и «проверка не выполнена». Для `2026-07-02` диапазон должен быть `2026-06-29…2026-07-05`; проверь также переход года.
|
||||
- **№ 15 — некорректные временные слоты.** Требуй `startTime < endTime`, запрети пересечения интервалов внутри scope, но разреши соседние интервалы с общей границей. Не разрешай перевод используемого базового слота из `DEFAULT` в `MANUAL`, а продолжительность рассчитывай на backend по `startTime/endTime`. Защити инвариант и от конкурентных записей.
|
||||
- **№ 16 — пересечения учебных лет и семестров.** Централизуй валидацию диапазонов для create/update: запрети пересечения учебных лет, пересечения семестров, выход семестра за границы его года и дубликаты title/type. Добавь DB-ограничения новой миграцией там, где PostgreSQL может надёжно обеспечить инвариант.
|
||||
- **№ 17 — устаревшие назначения календарей.** При изменении ключевых измерений календаря или группы либо безопасно отклоняй несовместимое изменение с `409 Conflict` без изменения данных, либо в одной транзакции перевалидируй назначения, grid и subjects. Уменьшение `courseCount` не должно оставлять строки или дисциплины старших курсов.
|
||||
- **№ 18 — исторические кафедры преподавателя.** Для бизнес-решений и исторических отчётов определяй кафедру через `teacher_department_assignments` на целевую дату. Будущий перевод не должен менять текущую принадлежность раньше `validFrom`, архивный преподаватель должен корректно отображаться в историческом периоде, дополнительные активные назначения должны работать единообразно.
|
||||
- **№ 19 — доступ к workload чужой кафедры.** Для роли `DEPARTMENT` принудительно ограничивай scope кафедрой из `AuthContext`; глобальный просмотр разрешай только явно уполномоченным ролям.
|
||||
- **№ 20 — лимит 50 групп в агрегатах.** Не используй ограничение интерактивного широкого поиска в workload/free-classrooms. Реализуй специализированные агрегирующие запросы или контролируемую batch-обработку и тесты как минимум с 51 и 100 активными группами.
|
||||
- **№ 21 — импорт дисциплины другой кафедры.** Не переназначай чужую запись по совпавшему глобальному имени. Зафиксируй бизнес-правило владения и уникальности; безопасный вариант по умолчанию — `409 Conflict` при совпадении с чужой кафедрой и идемпотентный повторный импорт своей записи. При необходимости введи `(department_id, lower(name))` новой миграцией и обработай существующие данные безопасно.
|
||||
- **№ 22 — несовпадающие права учебного отдела.** Создай единый явно проверяемый role matrix для backend и frontend. Либо выдай `EDUCATION_OFFICE` необходимые права, либо скрой недоступные действия; штатные экраны не должны завершать инициализацию с `403`.
|
||||
- **№ 23 — N+1 при генерации расписания.** Батчем загружай semesters, assignments и calendar days на диапазон, переиспользуй найденные данные и не выполняй запрос на каждую дату/группу/правило. Добавь измеримый query-count тест на диапазоне 120 дней и десятках групп, доказывающий отсутствие роста порядка `дни × группы × правила` при неизменном результате.
|
||||
- **№ 29 — нефиксированный часовой пояс.** Внедри `Clock` и явно настроенный `ZoneId` `Europe/Moscow` для бизнес-дат, используй `Instant`/UTC для абсолютных timestamp, зафиксируй timezone контейнеров и формируй frontend date-only строки из локальных компонентов без `toISOString()`.
|
||||
- **№ 31 — некорректный размер группы.** Используй единый валидатор create/update: `groupSize > 0`, `yearStartStudy > 0`, а также запрет уменьшения ниже суммы активных подгрупп, включая конкурентный сценарий. Добавь новые CHECK constraints отдельной миграцией.
|
||||
- **№ 32 — диапазон в 121 дату.** Проверяй включительную длину диапазона; максимум должен составлять ровно 120 календарных дат. Покрой граничные случаи 120 и 121 дата.
|
||||
|
||||
### Tenant, инфраструктура и поставка
|
||||
|
||||
- **№ 9 — небезопасная замена tenant DataSource.** Создавай временный pool, явно проверяй соединение и Flyway, затем атомарно подменяй рабочий DataSource. Ошибка миграции или сохранения конфигурации должна прерывать операцию и запускать compensating rollback без потери старого подключения.
|
||||
- **№ 10 — lost update ConfigMap и неинформативные probes.** Реализуй optimistic locking по `resourceVersion` с ограниченным retry/backoff либо эквивалентный единый coordinator; параллельные изменения двух pod не должны теряться. Readiness должна учитывать доступность обязательных tenant-БД и успешность миграций, а liveness — только жизнеспособность процесса, чтобы отказ БД не создавал restart storm.
|
||||
- **№ 13 — неполная синхронизация watcher.** Сравнивай весь нормализованный `TenantConfig`, а hash обновляй только после полного успеха. Неудачная синхронизация должна повторяться с ограниченным backoff и наблюдаемым русскоязычным логом.
|
||||
- **№ 26 — нерабочий чистый локальный запуск.** Сделай `docker compose up -d --build` на чистой машине, без заранее созданной внешней сети, достаточным для доступа к приложению по `http://localhost:80` и проксирования `/api` в backend. Согласуй JDBC URL и `POSTGRES_DB`, передай JWT-настройки, добавь именованный volume и обнови quick start.
|
||||
- **№ 27 — deployment без тестов и неверный tag rollout.** Добавь обязательные backend/frontend/static/config проверки до build/push/deploy, concurrency lock и проверяемый rollback. Для релизного tag разворачивай конкретный tag или digest, а не перезапускай mutable `:main`.
|
||||
- **№ 30 — mutable и непроверяемые артефакты.** Зафиксируй версии и digest базовых/сторонних образов и инструментов, проверяй SHA256/signature скачиваемых бинарников, формируй SBOM и добавь сканирование образов. В production не должно остаться `latest` и `:main`; обновление версии должно быть явным и воспроизводимым.
|
||||
|
||||
## Архитектурные требования к реализации
|
||||
|
||||
- Транзакционные границы должны находиться на вызываемых Spring proxy-методах; не рассчитывай на `@Transactional` при self-invocation или пойманном внутри исключении.
|
||||
- Для конкурентных сценариев используй гарантии PostgreSQL и проверяй их интеграционными тестами, а не только синхронизацией внутри одного JVM-процесса.
|
||||
- Все операции с tenant-конфигурацией должны быть идемпотентными и безопасными при двух pod.
|
||||
- Не смешивай date-only и timestamp. Date-only передавай как `YYYY-MM-DD` без UTC-конвертации.
|
||||
- Сохраняй обратную совместимость API там, где она не противоречит безопасности. Если контракт необходимо изменить, синхронно обнови backend, frontend, тесты и `docs/API.md`.
|
||||
- Не дублируй сложные бизнес-правила по контроллерам. Выноси общую валидацию/логику в переиспользуемые компоненты в соответствии с текущей архитектурой проекта.
|
||||
- Избегай массовых косметических рефакторингов, не относящихся к отчёту. Каждое существенное изменение должно сопоставляться с одним или несколькими номерами проблем.
|
||||
- Не выполняй `docker compose down -v`, массовое удаление данных и другие необратимые операции. Интеграционные тесты должны использовать изолированные временные ресурсы.
|
||||
|
||||
## Обязательные тесты и проверки
|
||||
|
||||
Добавь регрессионные тесты для каждого исправленного поведения. В первую очередь обязательно покрой:
|
||||
|
||||
1. PostgreSQL/Testcontainers и Flyway: чистая схема и upgrade существующей схемы — **без проверки и изменения поведения из проблемы № 2**.
|
||||
2. Два параллельных refresh-запроса с одним токеном: успех только одного.
|
||||
3. Последовательность `403`, затем публичный/защищённый запрос на одном потоке без утечки `AuthContext`.
|
||||
4. Override с конфликтом и расписание преподавателя, назначенного через `newTeacher`.
|
||||
5. Внутренние конфликты нового правила, неверная роль, нечётные часы и недопустимый формат.
|
||||
6. Атомарность `saveGrid`: после ошибочного payload старая сетка полностью сохранена.
|
||||
7. Изменение tenant URL/credentials, неудачная миграция, неудачный ConfigMap update и параллельные PATCH от двух pod.
|
||||
8. Более 50 активных групп для workload/free-classrooms.
|
||||
9. Будущий перевод преподавателя и исторические отчёты.
|
||||
10. E2E или эквивалентный интеграционный сценарий `EDUCATION_OFFICE` для календарных графиков и форм обучения.
|
||||
11. Frontend-даты в первые дни месяца и в интервале 00:00–03:00 `Europe/Moscow`.
|
||||
12. Граничные диапазоны 120/121 дата, XSS payload в ошибке, rate limit и cleanup refresh-токенов.
|
||||
|
||||
После реализации выполни доступные проверки и зафиксируй фактический результат каждой команды:
|
||||
|
||||
```bash
|
||||
mvn -f backend/pom.xml test
|
||||
find frontend -type f -name '*.js' -print0 | xargs -0 -n1 node --check
|
||||
docker compose config --quiet
|
||||
docker compose build backend frontend
|
||||
kubectl kustomize ../k8s >/dev/null
|
||||
bash -n ../k8s/deploy.sh
|
||||
git diff --check
|
||||
git diff --stat
|
||||
git status --short
|
||||
```
|
||||
|
||||
Команду для `../k8s/deploy.sh` выполняй только если файл существует. Если локального Maven, Node, Docker или kubectl нет либо сборке нужен недоступный network/daemon, используй уже принятый в проекте контейнерный эквивалент либо честно укажи, какую проверку невозможно запустить и почему. Не называй непроверенный результат успешным. Успешный Docker build не заменяет `mvn test`.
|
||||
|
||||
Дополнительно проверь:
|
||||
|
||||
- отсутствие секретов и placeholder credentials в отслеживаемых production-файлах;
|
||||
- отсутствие изменений существующих Flyway-миграций;
|
||||
- согласованность новых миграций с JPA-моделями и upgrade-путём всех tenant-БД;
|
||||
- отсутствие сырых exception messages в HTTP-ответах;
|
||||
- соответствие backend/frontend role matrix;
|
||||
- отсутствие `localStorage`/`sessionStorage` для access JWT, удалённых исполняемых модулей и исполняемого inline JavaScript, несовместимого со строгим CSP;
|
||||
- воспроизводимость Docker/CI/Kubernetes-конфигурации;
|
||||
- отсутствие английских пользовательских сообщений и production-логов в изменённых областях.
|
||||
- отдельный список и содержательную проверку всех изменений в `../k8s/`, поскольку они не видны в Git diff проекта.
|
||||
|
||||
## Документация
|
||||
|
||||
После кода и тестов обнови только относящуюся к изменениям документацию, в том числе при необходимости:
|
||||
|
||||
- `docs/API.md`;
|
||||
- `docs/DATABASE.md`;
|
||||
- `docs/ARCHITECTURE.md`;
|
||||
- `docs/BUSINESS_LOGIC.md`;
|
||||
- `docs/FRONTEND.md`;
|
||||
- `docs/INFRASTRUCTURE.md`;
|
||||
- `docs/LOGGING.md`;
|
||||
- `docs/README.md` и корневой quick start.
|
||||
|
||||
Документация должна описывать фактическое поведение после изменений. Не документируй проблему № 2 как исправленную и не меняй инструкции по demo-аккаунтам в рамках этой задачи.
|
||||
|
||||
## Формат итогового ответа
|
||||
|
||||
Ответ дай на русском языке и начни с результата. Включи:
|
||||
|
||||
1. Таблицу по всем пунктам `1, 3–34`: номер, краткое исправление, основные файлы, тест/проверка, статус.
|
||||
2. Список созданных Flyway-миграций и подтверждение, что существующие миграции не изменены.
|
||||
3. Фактически выполненные команды и их результаты.
|
||||
4. Изменения API, конфигурации, role matrix и deployment-процесса.
|
||||
5. Отдельный раздел «Требуемые действия оператора» для ротации секретов, очистки истории, настройки secret manager, production rollout и других действий, которым нужен внешний доступ. Не утверждай, что они выполнены.
|
||||
6. Оставшиеся риски или проверки, которые невозможно выполнить локально.
|
||||
7. Подтверждение, что проблема № 2 не затрагивалась.
|
||||
|
||||
Перед финальным ответом ещё раз прочитай `BUG_REPORT.md` и сравни чек-лист с точным множеством `{1} ∪ {3..34}`. В итоговой таблице должно быть ровно 33 строки без пропущенных или повторяющихся ID. У каждого пункта должны быть реализация либо доказательство уже существующего исправления, регрессионная проверка и честный статус.
|
||||
|
||||
Критерий завершения: каждый пункт № 1 и № 3–34 имеет реализацию и регрессионную проверку либо явно отделённое внешнее действие; все доступные тесты проходят; документация соответствует коду; проблема № 2 и существующие Flyway-миграции не изменены.
|
||||
614
BUG_REPORT.md
Normal file
614
BUG_REPORT.md
Normal file
@@ -0,0 +1,614 @@
|
||||
# Отчёт по ошибкам и рискам проекта Magistr
|
||||
|
||||
Дата проверки: 2026-07-10
|
||||
|
||||
## Объём и способ проверки
|
||||
|
||||
Проверены:
|
||||
|
||||
- 166 Java-файлов backend, JPA-модели, репозитории, контроллеры, сервисы и конфигурация мультитенантности;
|
||||
- обе Flyway-миграции без изменения существующих файлов;
|
||||
- 23 JavaScript-файла, встроенный JavaScript страниц преподавателя/студента, HTML/CSS и SPA-маршрутизация;
|
||||
- `compose.yaml`, Dockerfile, Gitea Actions;
|
||||
- production-манифесты `../k8s/`, которые AGENTS.md относит к внешним зависимостям проекта;
|
||||
- проектная документация и существующий граф связей `graphify-out/graph.json`.
|
||||
|
||||
Выполненные проверки:
|
||||
|
||||
- `mvn test` в Maven-контейнере: **30 тестов, 0 failures, 0 errors, BUILD SUCCESS**;
|
||||
- `node --check` для всех внешних JS-файлов и извлечённых inline-скриптов: ошибок синтаксиса нет;
|
||||
- `docker compose config --quiet`: Compose синтаксически корректен;
|
||||
- `kubectl kustomize ../k8s`: Kustomize-манифесты собираются;
|
||||
- сопоставление JPA-таблиц с Flyway DDL: отсутствующих таблиц для сущностей не найдено;
|
||||
- `git diff --check`: проблем с пробелами и маркерами конфликтов нет.
|
||||
|
||||
Ограничения проверки: не выполнялись E2E-тесты в браузере, нагрузочное тестирование, тестирование с реальным PostgreSQL и одновременной работой двух backend-pod. Конкурентные дефекты ниже подтверждены по коду и воспроизводимому сценарию, но не запускались против production-кластера.
|
||||
|
||||
## Сводка
|
||||
|
||||
| Приоритет | Количество |
|
||||
|---|---:|
|
||||
| Критический | 2 |
|
||||
| Высокий | 10 |
|
||||
| Средний | 19 |
|
||||
| Низкий | 3 |
|
||||
| **Всего** | **34** |
|
||||
|
||||
## Критические проблемы
|
||||
|
||||
### 1. Production-манифесты содержат известные JWT/DB-секреты и хранят пароли в ConfigMap
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/resources/application.properties:18-22`
|
||||
- `backend/src/main/java/com/magistr/app/config/auth/JwtProperties.java:13-17`
|
||||
- `../k8s/config.yaml:15-26`
|
||||
- `../k8s/backend.yaml:1-23`
|
||||
- `../k8s/otel-collector.yaml:1-25`
|
||||
|
||||
**Проблема:** приложение имеет известный JWT-секрет по умолчанию и небезопасный дефолт `refresh-cookie-secure=false`. Production-манифест `config.yaml` передаёт другой, но также заранее известный placeholder-секрет, который удовлетворяет проверке длины. Пароли PostgreSQL записаны открытым текстом в `Secret.stringData`, tenant ConfigMap и ConfigMap коллектора OpenTelemetry. Kubernetes Secret в YAML не шифрует значение в исходном файле, а ConfigMap вообще не предназначен для секретов.
|
||||
|
||||
**Риск:** подделка access JWT, компрометация всех tenant-БД и метрик, повторное использование опубликованных credentials. Если эти значения когда-либо применялись, их нужно считать скомпрометированными.
|
||||
|
||||
**Как исправить:**
|
||||
|
||||
- Немедленно ротировать JWT- и DB-секреты во всех окружениях.
|
||||
- Удалить реальные/фиксированные значения из файлов и истории репозиториев.
|
||||
- Использовать External Secrets, SOPS/Sealed Secrets или секреты CI/CD; tenant credentials и credentials OTel хранить в Secret, а не ConfigMap.
|
||||
- На production-профиле завершать запуск при пустом, дефолтном или известном placeholder `JWT_SECRET` и при `JWT_REFRESH_COOKIE_SECURE != true`.
|
||||
|
||||
---
|
||||
|
||||
### 2. Flyway создаёт во всех новых tenant-БД предсказуемые учётные записи и демонстрационные данные
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/resources/db/migration/V1__init.sql:21-24,38-42,85-92,221-265`
|
||||
- `docs/README.md:45-54`
|
||||
|
||||
**Проблема:** V1 создаёт администратора с публично документированным коротким паролем, а также несколько тестовых пользователей с одинаковым известным паролем. Эта же миграция запускается для tenant-БД, добавленных в production, и одновременно добавляет демонстрационные кафедры, группы и другие данные.
|
||||
|
||||
**Риск:** немедленный административный доступ к свежему tenant, если пароль не сменили вручную; загрязнение production-БД тестовыми сущностями.
|
||||
|
||||
**Как исправить:**
|
||||
|
||||
- Не менять V1. Добавить новую миграцию `V3__disable_demo_accounts.sql`, которая архивирует/удаляет демонстрационные аккаунты и данные там, где они не были явно сохранены.
|
||||
- Создавать первого администратора отдельной bootstrap-процедурой с одноразовым случайным секретом.
|
||||
- Разнести demo/dev seed и обязательную production-схему.
|
||||
- Ротировать пароли уже созданных tenant-БД.
|
||||
|
||||
## Высокий приоритет
|
||||
|
||||
### 3. Race condition при ротации refresh-токена
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/java/com/magistr/app/config/auth/RefreshTokenService.java:50-91`
|
||||
- `backend/src/main/java/com/magistr/app/repository/AuthRefreshTokenRepository.java:10`
|
||||
|
||||
**Проблема:** `rotate()` читает токен, проверяет его активность, а затем отзывает старый и создаёт новый без блокировки строки или условного атомарного UPDATE. Два параллельных запроса с одним cookie могут оба пройти проверку и создать две новые сессии.
|
||||
|
||||
**Риск:** повторное использование refresh-токена, раздвоение цепочки ротации и обход ожидаемой семантики single-use.
|
||||
|
||||
**Как исправить:** использовать `PESSIMISTIC_WRITE` либо атомарный `UPDATE ... WHERE revoked_at IS NULL AND expires_at > now()` и создавать следующий токен только при обновлении одной строки. Добавить конкурентный интеграционный тест с PostgreSQL.
|
||||
|
||||
---
|
||||
|
||||
### 4. `AuthContext` остаётся в servlet-потоке после отказа по роли
|
||||
|
||||
**Файл:** `backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java:45-49,65-68`
|
||||
|
||||
**Проблема:** пользователь записывается в ThreadLocal до проверки `@RequireRoles`. Если interceptor возвращает `false`, его собственный `afterCompletion()` не вызывается, поэтому контекст не очищается.
|
||||
|
||||
**Риск:** следующий запрос на том же потоке может увидеть данные предыдущего пользователя, особенно если он проходит через публичный auth endpoint или останавливается в более раннем interceptor.
|
||||
|
||||
**Как исправить:** очищать контекст в начале `preHandle()` и перед каждым `return false`; ещё лучше — устанавливать пользователя только после успешной проверки роли. Добавить MockMvc-тест последовательных запросов на одном executor-потоке.
|
||||
|
||||
---
|
||||
|
||||
### 5. Точечные изменения расписания создаются без проверки конфликтов и факта существования пары
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java:71-126`
|
||||
- `backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java:67-141`
|
||||
|
||||
**Проблема:** для `MOVE`/`REPLACE` проверяется только существование сущностей. Не проверяется занятость преподавателя, аудитории и групп в новой паре. Также можно сохранить override на дату, когда базовый слот вообще не генерируется, либо сохранить фактически пустой `REPLACE`.
|
||||
|
||||
**Риск:** двойное назначение ресурсов; накопление неработающих изменений, которые API считает успешными.
|
||||
|
||||
**Как исправить:** перед сохранением построить исходную пару на указанную дату, проверить action-specific поля и пересечения уже применённого расписания. При конфликте возвращать `409 Conflict`.
|
||||
|
||||
---
|
||||
|
||||
### 6. Расписание преподавателя не показывает пары, назначенные ему через override
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java:47-63`
|
||||
- `backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java:36-58`
|
||||
- `backend/src/main/java/com/magistr/app/repository/ScheduleOverrideRepository.java:14-26`
|
||||
|
||||
**Проблема:** запрос только по `teacherId` сначала выбирает базовые правила этого преподавателя. Если override заменяет преподавателя A на B, правило A не попадает в исходную выборку B, поэтому последующее `applyOverrides()` уже не может добавить пару.
|
||||
|
||||
**Риск:** преподаватель B не видит назначенную ему замену.
|
||||
|
||||
**Как исправить:** учитывать overrides с `newTeacher.id = teacherId` за период и достраивать соответствующие базовые пары до финальной фильтрации.
|
||||
|
||||
---
|
||||
|
||||
### 7. Валидатор правил расписания пропускает несколько нарушений инвариантов
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java:220-228,440-481,524-565`
|
||||
- `backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java:252-293`
|
||||
- `backend/src/main/resources/db/migration/V1__init.sql:707-726`
|
||||
|
||||
**Проблемы:**
|
||||
|
||||
- конфликт ищется только с уже сохранёнными правилами; два конфликтующих/дублирующих слота внутри одного нового правила не сравниваются между собой;
|
||||
- пользователь слота проверяется на архивность, но не на роль `TEACHER`;
|
||||
- `lessonFormat` проверяется только на непустоту, хотя DB допускает лишь «Очно»/«Онлайн»;
|
||||
- нечётное число академических часов принимается, хотя генератор списывает по два часа и может выдать больше часов, чем задано.
|
||||
|
||||
**Риск:** дубли в один момент времени, назначение студента/администратора преподавателем, HTTP 500 на DB CHECK и неверный расход часов.
|
||||
|
||||
**Как исправить:** валидировать пары новых слотов между собой, роль пользователя, enum формата и кратность часов двум; продублировать ключевые ограничения новой Flyway-миграцией там, где это возможно.
|
||||
|
||||
---
|
||||
|
||||
### 8. Ошибка в строке календарной сетки возвращает 400, но удаляет старую сетку и коммитит часть новой
|
||||
|
||||
**Файл:** `backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java:125-146`
|
||||
|
||||
**Проблема:** `saveGrid()` работает в транзакции, сначала удаляет всю старую сетку, затем валидирует и сохраняет строки по одной. `IllegalArgumentException` ловится внутри transactional-метода и не выходит за границу proxy, поэтому транзакция считается успешной.
|
||||
|
||||
**Воспроизведение:** передать список, где первая строка корректна, а вторая содержит неверный день/дату. API вернёт 400, но старая сетка будет удалена, а первая новая строка сохранится.
|
||||
|
||||
**Риск:** тихая потеря календарного учебного графика при ошибке пользователя.
|
||||
|
||||
**Как исправить:** сначала провалидировать весь список и уникальность ключей, затем одним шагом заменять данные; либо не перехватывать исключение внутри транзакции/помечать транзакцию rollback-only.
|
||||
|
||||
---
|
||||
|
||||
### 9. Обновление tenant может удалить рабочее подключение и успешно сохранить нерабочее
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/java/com/magistr/app/controller/DatabaseController.java:104-124,175-182`
|
||||
- `backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java:135-148`
|
||||
- `backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java:133-160`
|
||||
|
||||
**Проблема:** существующий DataSource сначала удаляется и закрывается. Новый Hikari pool создаётся с `initializationFailTimeout=-1`. Ошибка Flyway проглатывается, а результат записи ConfigMap игнорируется. API способен вернуть успех после неудачной миграции или неудачной персистенции.
|
||||
|
||||
**Риск:** одна опечатка в URL/пароле отключает рабочий tenant; разные pod получают разную конфигурацию.
|
||||
|
||||
**Как исправить:** создать временный pool, проверить соединение и миграции, затем атомарно заменить старый. Ошибки миграции и ConfigMap должны прерывать операцию; нужен compensating rollback.
|
||||
|
||||
---
|
||||
|
||||
### 10. Два backend-pod могут потерять изменения tenant-конфигурации, а probes не видят отказ БД
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `../k8s/backend.yaml:31,86-97`
|
||||
- `backend/src/main/java/com/magistr/app/controller/DatabaseController.java:177-182`
|
||||
- `backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java:62-92`
|
||||
|
||||
**Проблема:** deployment имеет две реплики. Каждая PATCH-операция перезаписывает весь `tenants.json` из локальной in-memory карты без resourceVersion/compare-and-swap. Параллельное добавление A на pod-1 и B на pod-2 приводит к last-write-wins и потере A или B. TCP readiness/liveness считает pod здоровым, даже если все tenant-БД недоступны и Flyway завершился ошибкой.
|
||||
|
||||
**Риск:** потерянные конфигурационные изменения и маршрутизация трафика на фактически неработающий pod.
|
||||
|
||||
**Как исправить:** вынести операции в единый coordinator/CRD/БД либо применять optimistic locking к ConfigMap с повтором; добавить health endpoint, проверяющий миграции и доступность обязательных tenant.
|
||||
|
||||
---
|
||||
|
||||
### 11. Дашборд может показать зелёное «конфликтов нет», когда проверка фактически не выполнена
|
||||
|
||||
**Файл:** `frontend/admin/js/views/dashboard.js:148-168,274-287`
|
||||
|
||||
**Проблемы:**
|
||||
|
||||
- диапазон недели вычисляется двойным мутированием одного `Date`; в первые дни месяца конец диапазона попадает в начало предыдущего месяца и оказывается раньше начала;
|
||||
- каждый запрос кафедры имеет `catch(() => [])`, поэтому сетевые/серверные ошибки превращаются в пустое расписание;
|
||||
- после этого пустой результат отображается как «Конфликты расписания не обнаружены».
|
||||
|
||||
**Проверенный пример:** для 2026-07-02 код строит диапазон примерно 2026-06-29 … 2026-06-05 вместо 2026-06-29 … 2026-07-05.
|
||||
|
||||
**Риск:** ложное подтверждение безопасности расписания именно в панели контроля.
|
||||
|
||||
**Как исправить:** вычислять воскресенье как `monday + 6 дней` на отдельном объекте, форматировать локальную дату без UTC и различать состояния «нет конфликтов»/«проверка частично или полностью не выполнена».
|
||||
|
||||
---
|
||||
|
||||
### 12. Access JWT и удалённый JavaScript выполняются в одном origin
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `frontend/admin/js/api.js:5-7,155-163`
|
||||
- `frontend/script.js:5-28`
|
||||
- `frontend/admin/js/otel.js:1-8`
|
||||
|
||||
**Проблема:** JWT хранится в `localStorage`. Страница входа динамически исполняет неприкреплённые по версии модули с `esm.sh`, а admin — удалённые модули без SRI. Такой код имеет доступ к DOM, введённому паролю и `localStorage`.
|
||||
|
||||
**Риск:** компрометация CDN/пакета или XSS сразу даёт учётные данные и Bearer-токен.
|
||||
|
||||
**Как исправить:** собирать и раздавать telemetry dependencies локально с lockfile; держать access token в памяти или перейти на защищённую cookie-сессию; добавить строгий CSP и убрать inline-скрипты.
|
||||
|
||||
## Средний приоритет
|
||||
|
||||
### 13. Watcher не применяет изменения существующего tenant и не повторяет неудачную синхронизацию
|
||||
|
||||
**Файл:** `backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java:60-71,102-125`
|
||||
|
||||
**Проблема:** сравниваются только множества доменов — смена URL, логина или пароля существующего домена игнорируется. Кроме того, новый hash записывается до JSON parsing/sync; если синхронизация падает, следующий poll считает файл уже обработанным и не повторяет операцию.
|
||||
|
||||
**Риск:** pod продолжает использовать старые credentials либо навсегда остаётся в частично синхронизированном состоянии до следующего изменения файла/рестарта.
|
||||
|
||||
**Как исправить:** сравнивать весь нормализованный `TenantConfig`, записывать hash только после полного успеха и сохранять retry/backoff-состояние.
|
||||
|
||||
---
|
||||
|
||||
### 14. Полностью отключена проверка TLS Kubernetes API
|
||||
|
||||
**Файл:** `backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java:74-76,104-119`
|
||||
|
||||
**Проблема:** trust manager принимает любой сертификат. Kubernetes CA уже доступен в serviceaccount volume.
|
||||
|
||||
**Риск:** MITM к API server, утечка serviceaccount token и подмена tenant ConfigMap.
|
||||
|
||||
**Как исправить:** загружать `/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`, использовать стандартную проверку цепочки и hostname.
|
||||
|
||||
---
|
||||
|
||||
### 15. Редактор временных слотов позволяет создать пересекающиеся интервалы и сломать базовые правила
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java:152-224`
|
||||
- `backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java:418-435`
|
||||
|
||||
**Проблемы:**
|
||||
|
||||
- проверяется только уникальность номера пары, но не пересечение временных интервалов внутри сетки;
|
||||
- update разрешает перенести существующий базовый слот в MANUAL scope, хотя правила обязаны ссылаться на DEFAULT;
|
||||
- `durationMinutes` может не соответствовать разнице `startTime/endTime`.
|
||||
|
||||
**Риск:** два занятия реально идут одновременно, но конфликтный анализ считает их разными слотами; существующие правила исчезают из ожидаемой базовой сетки.
|
||||
|
||||
**Как исправить:** запретить пересечение интервалов, фиксировать scope используемого базового слота и вычислять duration на backend.
|
||||
|
||||
---
|
||||
|
||||
### 16. Учебные годы и семестры допускают пересечения и выход семестра за границы года
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java:46-129,188-237`
|
||||
- `backend/src/main/java/com/magistr/app/service/AcademicDateService.java:32-34`
|
||||
- `backend/src/main/java/com/magistr/app/repository/SemesterRepository.java:13-17`
|
||||
|
||||
**Проблема:** проверяется только порядок двух дат. Можно пересечь учебные годы, пересечь осенний и весенний семестры, вынести семестр за границы года. При update также не проверяется дубликат title/type до DB constraint.
|
||||
|
||||
**Риск:** `findFirst...` выбирает произвольный семестр для даты; номер недели и чётность становятся недетерминированными, а часть update-запросов заканчивается HTTP 500.
|
||||
|
||||
**Как исправить:** централизовать календарную валидацию, запретить пересечения и добавить DB-level exclusion/range constraints новой миграцией.
|
||||
|
||||
---
|
||||
|
||||
### 17. Изменение календаря или группы нарушает уже сохранённые назначения
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java:85-98,178-214`
|
||||
- `backend/src/main/java/com/magistr/app/controller/GroupController.java:173-212,322-359`
|
||||
|
||||
**Проблема:** после назначения графика группе можно изменить у графика учебный год, специальность, профиль, форму или количество курсов; можно также изменить эти поля у группы. Существующее `student_group_calendar_assignments` не перепроверяется и не удаляется. При уменьшении courseCount остаются строки сетки старших курсов.
|
||||
|
||||
**Риск:** расписание строится по графику, который больше не соответствует группе/году, хотя API проверял соответствие при первоначальном назначении.
|
||||
|
||||
**Как исправить:** запрещать изменение ключевых измерений у используемого графика либо транзакционно перевалидировать назначения, grid и subjects.
|
||||
|
||||
---
|
||||
|
||||
### 18. Историческая модель кафедр преподавателя используется непоследовательно
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/java/com/magistr/app/controller/UserController.java:218-259,310-324`
|
||||
- `backend/src/main/java/com/magistr/app/controller/WorkloadController.java:51-60,85-100`
|
||||
- `backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java:102-115`
|
||||
|
||||
**Проблемы:**
|
||||
|
||||
- будущий перевод немедленно меняет `users.department_id`, и преподаватель до `validFrom` появляется одновременно в старой и новой кафедре;
|
||||
- исторический список фильтрует `User::isActiveRecord`, поэтому архивный преподаватель исчезает даже для даты, когда он работал;
|
||||
- workload прошлых периодов относится к текущему `users.department_id`;
|
||||
- дополнительная активная кафедральная связь учитывается в списке преподавателей, но `TeacherSubjectController` разрешает связь только по legacy `users.department_id`.
|
||||
|
||||
**Риск:** неверные исторические отчёты и неработоспособность дополнительного назначения преподавателя.
|
||||
|
||||
**Как исправить:** считать кафедру через `teacher_department_assignments` на дату; legacy-поле обновлять только в дату вступления перевода в силу либо не использовать для бизнес-решений.
|
||||
|
||||
---
|
||||
|
||||
### 19. Роль `DEPARTMENT` может запрашивать workload чужой кафедры
|
||||
|
||||
**Файл:** `backend/src/main/java/com/magistr/app/controller/WorkloadController.java:43-135`
|
||||
|
||||
**Проблема:** endpoints принимают произвольный `departmentId` или отсутствие фильтра и не сопоставляют его с `AuthContext.departmentId`.
|
||||
|
||||
**Риск:** межкафедральное раскрытие отчётов и обход ограничений, которые уже применяются в `DepartmentWorkspaceController` и части `UserController`.
|
||||
|
||||
**Как исправить:** для `DEPARTMENT` принудительно использовать кафедру из токена; явно документировать endpoints, где глобальный просмотр действительно разрешён.
|
||||
|
||||
---
|
||||
|
||||
### 20. Глобальные workload/free-classrooms ломаются при количестве активных групп больше 50
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/java/com/magistr/app/controller/WorkloadController.java:43-135`
|
||||
- `backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java:160-180`
|
||||
|
||||
**Проблема:** агрегирующие endpoints переиспользуют общий поиск с ограничением 50 групп. `free-classrooms` вообще не принимает scope, поэтому в крупном tenant неизбежно получает `IllegalArgumentException` вместо списка свободных аудиторий.
|
||||
|
||||
**Риск:** штатная функция перестаёт работать при росте университета.
|
||||
|
||||
**Как исправить:** сделать специализированные агрегирующие запросы/батч-генерацию, а не использовать ограничение интерактивного широкого поиска.
|
||||
|
||||
---
|
||||
|
||||
### 21. Импорт кафедры может переназначить дисциплину другой кафедры
|
||||
|
||||
**Файл:** `backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java:80-90`
|
||||
|
||||
**Проблема:** дисциплина ищется по глобальному имени, после чего `departmentId` безусловно меняется на текущую кафедру.
|
||||
|
||||
**Риск:** потеря принадлежности и изменение расписаний/календарей другой кафедры.
|
||||
|
||||
**Как исправить:** не менять чужую запись; решить бизнес-правило уникальности и при необходимости перейти на `(department_id, lower(name))` новой миграцией.
|
||||
|
||||
---
|
||||
|
||||
### 22. Права frontend и backend расходятся для учебного отдела
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/java/com/magistr/app/controller/SpecialityController.java:23-24,38-58,196-213`
|
||||
- `backend/src/main/java/com/magistr/app/controller/EducationFormController.java:17-18,33-59`
|
||||
- `frontend/admin/js/views/academic-calendar.js:106-118,207-218`
|
||||
- `frontend/admin/settings/js/main.js:47-52`
|
||||
|
||||
**Проблемы:**
|
||||
|
||||
- `EDUCATION_OFFICE` видит вкладку календарных графиков, но её обязательные GET `/api/specialties` и profiles разрешены только `ADMIN`; инициализация попадает в 403;
|
||||
- settings показывает учебному отделу CRUD форм обучения, но POST/DELETE backend разрешены только `ADMIN`.
|
||||
|
||||
**Риск:** видимые штатные экраны частично или полностью не работают.
|
||||
|
||||
**Как исправить:** согласовать role matrix в одном источнике; либо расширить read/write права, либо скрыть недоступные действия.
|
||||
|
||||
---
|
||||
|
||||
### 23. Генерация расписания имеет выраженный N+1 по дням, правилам и группам
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java:64-90,107-124,187-229,297-308`
|
||||
- `backend/src/main/java/com/magistr/app/service/AcademicDateService.java:58-87`
|
||||
|
||||
**Проблема:** семестр повторно ищется внутри каждого правила, а `isTheoryDay()` для каждой группы и даты выполняет отдельные запросы assignment + calendar day. В teacher mode это повторяется по всем группам правила.
|
||||
|
||||
**Риск:** диапазон 120 дней и десятки групп порождают тысячи/десятки тысяч SQL-запросов, таймауты и нагрузку на каждую tenant-БД.
|
||||
|
||||
**Как исправить:** заранее батчем загружать semesters, assignments и calendar days за диапазон; передавать уже найденный semester в `processRuleForDate()`. Добавить метрики query count и нагрузочный тест.
|
||||
|
||||
---
|
||||
|
||||
### 24. Истёкшие и отозванные refresh-токены никогда не удаляются
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/java/com/magistr/app/config/auth/RefreshTokenService.java:34-107`
|
||||
- `backend/src/main/java/com/magistr/app/repository/AuthRefreshTokenRepository.java:8-11`
|
||||
|
||||
**Проблема:** каждый login/refresh добавляет строку, ротация лишь ставит `revoked_at`. Cleanup job/repository delete отсутствует.
|
||||
|
||||
**Риск:** неограниченный рост `auth_refresh_tokens` и индексов.
|
||||
|
||||
**Как исправить:** периодически удалять давно истёкшие/отозванные строки с разумным audit retention; покрыть job тестом.
|
||||
|
||||
---
|
||||
|
||||
### 25. DB constraint violations превращаются в 500 и иногда раскрывают текст драйвера
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java:23-55`
|
||||
- `backend/src/main/java/com/magistr/app/controller/GroupController.java:166-169,216-219`
|
||||
- `backend/src/main/java/com/magistr/app/controller/SubjectController.java:122-125`
|
||||
- `backend/src/main/java/com/magistr/app/controller/DatabaseController.java:121-124,166-171`
|
||||
|
||||
**Проблема:** нет общего обработчика `DataIntegrityViolationException`/constraint name. Часть контроллеров ловит общий `Exception` и возвращает клиенту `e.getMessage()`.
|
||||
|
||||
**Риск:** неправильный HTTP status, английские/технические сообщения в UI и раскрытие деталей схемы/JDBC.
|
||||
|
||||
**Как исправить:** добавить единый перевод constraint → 409/400 с русским сообщением; не отдавать внутренний exception text.
|
||||
|
||||
---
|
||||
|
||||
### 26. Чистый локальный запуск по документации не публикует приложение и имеет противоречивую конфигурацию БД
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `compose.yaml:1-43`
|
||||
- `backend/src/main/resources/application.properties:3-6,18-22`
|
||||
- `docs/README.md:33-43`
|
||||
- `docs/INFRASTRUCTURE.md:3-37`
|
||||
|
||||
**Проблемы:**
|
||||
|
||||
- frontend/backend не имеют `ports`, а Caddy не входит в Compose, поэтому на чистой машине `localhost:80` после указанной команды недоступен;
|
||||
- `POSTGRES_DB` может изменить создаваемую DB, но backend URL жёстко указывает `app_db`;
|
||||
- JWT-переменные из `.env` не передаются контейнеру backend;
|
||||
- для PostgreSQL не объявлен именованный volume, поэтому после down/recreate данные могут остаться в потерянном anonymous volume.
|
||||
|
||||
**Как исправить:** добавить локальный reverse proxy/ports, передавать согласованный JDBC URL и JWT env, объявить named volume и обновить quick start.
|
||||
|
||||
---
|
||||
|
||||
### 27. CI/CD разворачивает код без запуска тестов, а tag pipeline перезапускает `:main`
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `.gitea/workflows/docker-build.yaml:3-8,35-40,63-68,73-96`
|
||||
- `backend/Dockerfile:1-6`
|
||||
- `../k8s/backend.yaml:45-46`
|
||||
- `../k8s/frontend.yaml:20-21`
|
||||
|
||||
**Проблема:** workflow не имеет test job, а Dockerfile выполняет `mvn package -DskipTests`. На событии tag metadata публикует tag-образ, но deployment продолжает ссылаться на mutable `:main` и просто перезапускается.
|
||||
|
||||
**Риск:** production получает непроверенный commit; release-tag может перезапустить старый main-образ вместо релиза.
|
||||
|
||||
**Как исправить:** обязательные test/compile/static-check jobs до push/deploy; деплой image digest или конкретного release tag; concurrency lock и rollback при failed rollout.
|
||||
|
||||
---
|
||||
|
||||
### 28. Нет ограничения попыток входа
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/java/com/magistr/app/controller/AuthController.java:57-86`
|
||||
- `../k8s/ingress.yaml:1-45`
|
||||
|
||||
**Проблема:** login не имеет rate limit, задержки, lockout или CAPTCHA; в Ingress также нет соответствующего middleware/аннотации.
|
||||
|
||||
**Риск:** online brute force и password spraying, особенно опасные вместе с известными seed-аккаунтами.
|
||||
|
||||
**Как исправить:** rate limit по tenant+IP+username, прогрессивная задержка и аудит неудачных входов без логирования пароля.
|
||||
|
||||
---
|
||||
|
||||
### 29. Часовой пояс backend не зафиксирован, а часть frontend дат строится через UTC
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/java/com/magistr/app/model/LifecycleEntity.java:94-98`
|
||||
- `backend/src/main/java/com/magistr/app/controller/UserController.java:85,164,235,271`
|
||||
- `backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java:236`
|
||||
- `frontend/admin/js/views/dashboard.js:48,159-160`
|
||||
- `frontend/admin/js/views/department-workspace.js:494-504`
|
||||
- `compose.yaml` и `../k8s/backend.yaml`
|
||||
|
||||
**Проблема:** бизнес-даты используют `LocalDate.now()`/`LocalDateTime.now()` с timezone JVM, но контейнеры не получают `TZ`/`user.timezone`. Frontend использует `toISOString()`, поэтому в Москве до 03:00 получает предыдущую календарную дату.
|
||||
|
||||
**Риск:** архивирование, перевод, доступность ресурсов и «сегодняшнее расписание» сдвигаются на день/несколько часов в зависимости от окружения.
|
||||
|
||||
**Как исправить:** внедрить `Clock` и явный `ZoneId` для бизнес-даты, хранить абсолютные timestamps как UTC/`Instant`, форматировать date-only локальными компонентами.
|
||||
|
||||
---
|
||||
|
||||
### 30. Сборки и deployment используют mutable/непроверяемые артефакты
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/Dockerfile:7`
|
||||
- `../k8s/otel-collector.yaml:59`
|
||||
- `../k8s/backend.yaml:45-46`
|
||||
- `../k8s/frontend.yaml:20-21`
|
||||
- `.gitea/workflows/docker-build.yaml:83-87`
|
||||
|
||||
**Проблема:** javaagent и kubectl скачиваются как latest/stable без checksum, collector использует `latest`, приложения — mutable `main`.
|
||||
|
||||
**Риск:** невоспроизводимые сборки, неожиданные несовместимости и supply-chain подмена.
|
||||
|
||||
**Как исправить:** pin версии и image digest, проверять SHA256/signature, генерировать SBOM и сканировать образы.
|
||||
|
||||
---
|
||||
|
||||
### 31. Группы допускают некорректную численность и ломают существующее деление на подгруппы
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/java/com/magistr/app/controller/GroupController.java:173-212,250-272`
|
||||
- `backend/src/main/java/com/magistr/app/controller/SubgroupController.java:151-175`
|
||||
- `backend/src/main/resources/db/migration/V1__init.sql:203-219`
|
||||
|
||||
**Проблема:** `groupSize` проверяется только на null, `yearStartStudy` — только на ноль; DB не имеет CHECK для положительной численности. При update можно уменьшить группу ниже суммы активных подгрупп, хотя SubgroupController такой результат запрещает при редактировании подгрупп.
|
||||
|
||||
**Риск:** отрицательная/нулевая численность, неверный расчёт вместимости и внутренне противоречивые подгруппы.
|
||||
|
||||
**Как исправить:** единый валидатор create/update, проверка суммы подгрупп и новая миграция с CHECK constraints.
|
||||
|
||||
## Низкий приоритет
|
||||
|
||||
### 32. Лимит «120 дней» фактически разрешает 121 дату
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java:148-157`
|
||||
- `backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java:135-144`
|
||||
|
||||
**Проблема:** проверяется `DAYS.between(start, end) > 120`, но обе границы включены. Разница 120 означает 121 календарную дату.
|
||||
|
||||
**Как исправить:** проверять включительную длину (`between + 1`) либо изменить текст/документацию.
|
||||
|
||||
---
|
||||
|
||||
### 33. Остались XSS-точки в сообщениях ошибок и видимые поля пароля
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `frontend/admin/settings/js/views/database.js:39,49`
|
||||
- `frontend/admin/js/main.js:260-262`
|
||||
- `frontend/admin/settings/js/main.js:130-133`
|
||||
- `frontend/admin/views/users.html:11-12`
|
||||
- `frontend/admin/js/views/teacher-requests.js:47-51`
|
||||
|
||||
**Проблема:** несколько `e.message` вставляются через `innerHTML` без экранирования. Поля пароля при создании пользователя/одобрении заявки имеют `type="text"`.
|
||||
|
||||
**Риск:** потенциальный DOM XSS при попадании управляемого текста ошибки; пароль виден на экране и может сохраняться как обычный текст формы.
|
||||
|
||||
**Как исправить:** использовать `textContent`/`escapeHtml`, а для паролей — `type="password"` и корректные `autocomplete`.
|
||||
|
||||
---
|
||||
|
||||
### 34. Проектный языковой регламент нарушен в UI и логах
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java:65,78,95`
|
||||
- `backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java:60,123`
|
||||
- `frontend/admin/settings/js/views/database.js:14-16,60-62`
|
||||
- `frontend/admin/js/otel.js:47`
|
||||
|
||||
**Проблема:** присутствуют английские production-логи и UI-метки `Online/Offline`; JDBC exception также может попасть в UI на английском.
|
||||
|
||||
**Как исправить:** привести пользовательские сообщения и проектные логи к русскому языку; технические идентификаторы оставлять в structured fields.
|
||||
|
||||
## Что покрыть тестами в первую очередь
|
||||
|
||||
1. PostgreSQL/Testcontainers + Flyway: чистая БД, upgrade существующей БД, отсутствие активных demo-аккаунтов.
|
||||
2. Два параллельных refresh-запроса с одним токеном.
|
||||
3. Цепочка interceptor: 403, затем публичный/защищённый запрос на том же потоке.
|
||||
4. Override с конфликтом и поиск преподавателя, назначенного через `newTeacher`.
|
||||
5. Внутренние конфликты одного нового правила, роль преподавателя, нечётные часы и недопустимый формат.
|
||||
6. `saveGrid` с корректной и ошибочной строкой: после 400 старая сетка должна остаться целой.
|
||||
7. Изменение tenant URL/credentials, неудачная миграция, параллельные PATCH от двух pod.
|
||||
8. Более 50 активных групп для workload/free-classrooms.
|
||||
9. Future-dated перевод преподавателя и исторические отчёты.
|
||||
10. E2E для `EDUCATION_OFFICE`: календарные графики и настройки форм обучения.
|
||||
11. Frontend-тест дат в первые дни месяца и в 00:00–03:00 Europe/Moscow.
|
||||
|
||||
## Рекомендуемый порядок исправления
|
||||
|
||||
1. Ротация/удаление секретов и отключение seed-аккаунтов.
|
||||
2. Data-loss и security races: calendar grid, refresh rotation, AuthContext.
|
||||
3. Атомарность tenant-конфигурации и реальные health checks.
|
||||
4. Конфликты overrides/rules и корректность расписания преподавателя.
|
||||
5. Согласование ролей, календарных инвариантов и исторической модели кафедр.
|
||||
6. Производительность генератора, Compose/CI/CD и frontend hardening.
|
||||
|
||||
Существующие Flyway-миграции не изменять. Все исправления схемы оформлять новой миграцией, начиная с `V3__...sql`.
|
||||
60
DEVOPS.md
60
DEVOPS.md
@@ -88,18 +88,19 @@ graph TD
|
||||
|
||||
* **Frontend (`frontend.yaml`)**:
|
||||
* Реализован в виде `Deployment` с 2 репликами для обеспечения высокой доступности (HA) и возможности бесшовного обновления rolling-update.
|
||||
* В качестве базового образа контейнера применен легковесный веб-сервер Apache HTTPd (`httpd:alpine`).
|
||||
* В качестве базового образа контейнера применен легковесный веб-сервер Apache HTTPd; версия и manifest digest закреплены в Dockerfile.
|
||||
* Для балансировки и внутреннего доступа настроен `Service` типа ClusterIP, слушающий порт 80.
|
||||
|
||||
* **Backend (`backend.yaml`)**:
|
||||
* `Deployment` с 1-2 репликами (детали балансировки конфигурации описаны ниже).
|
||||
* Для сборки образов используется multi-stage сборка Maven (JDK 17) и запуск под управлением `eclipse-temurin:17-jre-alpine`.
|
||||
* Для сборки образов используется multi-stage сборка Maven (JDK 17); build/runtime-образы одновременно закреплены точным tag и manifest digest.
|
||||
* Интегрирован Java-агент OpenTelemetry для автоматического инструментирования трассировки и логов.
|
||||
* Для связи с Ingress настроен ClusterIP-сервис на порту 8080.
|
||||
|
||||
* **Ingress (`ingress.yaml`)**:
|
||||
* В роли Ingress-контроллера выступает стандартный для K3s **Traefik**.
|
||||
* Мной настроены строгие правила маршрутизации по доменным именам (например, `magistr.zuev.company` для тестового окружения, `n8n.zuev.company` и т.д.). Маршрутизация по путям разделяет трафик: запросы к API (`/api`) проксируются на бэкенд-сервис, а запросы к статическим ресурсам и страницам (`/`) — на фронтенд-сервис.
|
||||
* В роли Ingress-контроллера выступает встроенный **Traefik** (`kube-system/traefik`).
|
||||
* Он доступен извне через Klipper Service LoadBalancer (DaemonSet), который прокидывает порты 80/443 (hostPort) на внешние IP адреса нод кластера (`192.168.1.104`, `192.168.1.105`, `192.168.1.106`).
|
||||
* Мной настроены строгие правила маршрутизации по доменным именам (например, `magistr.zuev.company` и `n8n.zuev.company`). Запросы к API (`/api`) проксируются на бэкенд, а к корню (`/`) — на фронтенд.
|
||||
|
||||
### 3.2 Реализация динамической мультитенантности через K8s API
|
||||
Одним из наиболее сложных этапов проектирования стала организация бесшовного добавления новых университетов без перезапуска бэкенда и изменения исходного кода приложения.
|
||||
@@ -193,26 +194,27 @@ graph TD
|
||||
|
||||
### 5.1 Автоматизация сборки (CI)
|
||||
В репозитории проекта создан workflow-манифест `.gitea/workflows/docker-build.yaml`. При каждом пуше изменений в ветку `main` запускается конвейер:
|
||||
1. **Checkout**: Загрузка актуального исходного кода проекта на ранер.
|
||||
2. **Setup Buildx**: Инициализация Docker Buildx для оптимизации кэширования слоев.
|
||||
3. **Login to Registry**: Аутентификация во встроенном реестре контейнеров Gitea Container Registry (`git.zuev.company`) с использованием сервисного токена `ZUEV_TOKEN` (права `write:package`).
|
||||
4. **Build & Push**: Параллельная сборка Docker-образов для бэкенда и фронтенда с тегом `latest` и отправка их в приватный реестр.
|
||||
1. **Checks**: Backend/frontend-тесты, Compose validation, тест immutable rollback и статическая проверка закрепления артефактов.
|
||||
2. **Build & Push**: Параллельная сборка и публикация backend/frontend с SHA-tag или release tag; mutable `latest` не создаётся.
|
||||
3. **Attestations**: BuildKit добавляет к обоим образам SBOM и максимальную provenance-attestation.
|
||||
4. **Security scan**: Закреплённый digest Trivy блокирует доставку при исправимых уязвимостях `HIGH`/`CRITICAL`.
|
||||
5. **Deploy**: Только прошедшие gates registry digests передаются в production rollout. Все сторонние Actions закреплены полными commit SHA.
|
||||
|
||||
### 5.2 Доставка в кластер (CD)
|
||||
Для авторизации нод K3s в приватном реестре Gitea мной был создан секрет типа `docker-registry` в пространстве имен `magistr`:
|
||||
Для авторизации нод K3s в приватном реестре Gitea мной был создан секрет `gitea-registry` типа `docker-registry` в пространстве имен `magistr`. Этот секрет ассоциирован со спецификациями деплоев через директиву `imagePullSecrets`.
|
||||
|
||||
Непосредственно Gitea Actions Runner работает как демон `act_runner` внутри изолированного LXC контейнера (CTID 107). В пайплайне шаг развертывания (`deploy-to-k8s`) динамически генерирует `kubeconfig` из секрета, проверяет checksum закреплённой версии `kubectl` и выполняет атомарное обновление обоих образов без использования тяжеловесных GitOps операторов:
|
||||
|
||||
```bash
|
||||
kubectl create secret docker-registry gitea-registry \
|
||||
--docker-server=gitea.zuev.company \
|
||||
--docker-username=Zuev \
|
||||
--docker-password=${ZUEV_TOKEN} \
|
||||
--namespace=magistr
|
||||
```
|
||||
Этот секрет ассоциирован со спецификациями деплоев в `backend.yaml` и `frontend.yaml` через директиву `imagePullSecrets`. Обновление приложений в кластере после завершения сборки образов выполняется путем контролируемого перезапуска подов:
|
||||
```bash
|
||||
kubectl rollout restart deployment backend -n magistr
|
||||
kubectl rollout restart deployment frontend -n magistr
|
||||
bash scripts/deploy-images.sh \
|
||||
gitea.zuev.company/zuev/magistr-backend@sha256:<digest> \
|
||||
gitea.zuev.company/zuev/magistr-frontend@sha256:<digest>
|
||||
```
|
||||
|
||||
Скрипт принимает только полные `image@sha256:...`, ожидает готовность обоих Deployment и при
|
||||
ошибке возвращает предыдущую пару digest. Поэтому содержимое релиза не зависит от повторного
|
||||
разрешения mutable tag.
|
||||
|
||||
---
|
||||
|
||||
## РАЗДЕЛ 6. ВХОДНОЙ ПРОКСИ-СЕРВЕР НА БАЗЕ CADDY PROXY
|
||||
@@ -226,23 +228,35 @@ kubectl rollout restart deployment frontend -n magistr
|
||||
* Полноценной поддержке протокола HTTP/3 «из коробки».
|
||||
|
||||
### 6.2 Конфигурация балансировки
|
||||
Внешний Caddy-сервер принимает запросы ко всем поддоменам `*.zuev.company` и перенаправляет их на ноды кластера K3s, выполняя роль внешнего балансировщика нагрузки (L4/L7 Load Balancer):
|
||||
Внешний Caddy-сервер развернут в изолированном LXC контейнере (CTID 101) и обрабатывает запросы ко всем поддоменам проекта. Он перенаправляет их на ноды кластера K3s, выполняя роль внешнего балансировщика, а также проксирует телеметрию с фронтенда напрямую в сборщик OTel:
|
||||
|
||||
```caddyfile
|
||||
*.zuev.company {
|
||||
(otel_proxy) {
|
||||
handle_path /otel/* { reverse_proxy 192.168.1.100:4318 }
|
||||
}
|
||||
|
||||
(k8s_nodes) {
|
||||
handle {
|
||||
reverse_proxy 192.168.1.104:80 192.168.1.105:80 192.168.1.106:80 {
|
||||
lb_policy round_robin
|
||||
lb_try_duration 5s
|
||||
lb_try_interval 250ms
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
magistr.zuev.company { import otel_proxy; import k8s_nodes }
|
||||
n8n.zuev.company { import otel_proxy; import k8s_nodes }
|
||||
```
|
||||
Сетевые запросы распределяются по нодам K3s по алгоритму Round-Robin. В случае недоступности одной из нод, Caddy временно исключает ее из пула, обеспечивая отказоустойчивость инфраструктуры на сетевом уровне.
|
||||
Сетевые запросы к приложениям распределяются по 3 физическим нодам K3s. Благодаря сниппетам, добавление нового тенанта в инфраструктуре Caddy требует всего двух строчек конфигурации.
|
||||
|
||||
---
|
||||
|
||||
## РАЗДЕЛ 7. СКВОЗНОЙ МОНИТОРИНГ И ОБСЕРВАБИЛИТИ (SigNoz + OpenTelemetry)
|
||||
|
||||
Для контроля здоровья системы и оперативного выявления аномалий мной была спроектирована и внедрена централизованная система мониторинга на базе APM-платформы **SigNoz** и стандартов **OpenTelemetry (OTel)**.
|
||||
Для контроля здоровья системы и оперативного выявления аномалий мной была спроектирована и внедрена централизованная система мониторинга на базе APM-платформы **SigNoz** (с хранилищем **ClickHouse**) и стандартов **OpenTelemetry (OTel)**.
|
||||
|
||||
Платформа SigNoz вынесена в отдельный LXC контейнер (CTID 100) по адресу `192.168.1.100` и развернута через `docker-compose`. Данные ClickHouse и SQLite метаданные персистентно хранятся в Docker volumes хоста для обеспечения сохранности при рестартах.
|
||||
|
||||
### 7.1 Сбор телеметрии Backend
|
||||
Java-приложение бэкенда запускается с подключением агента OpenTelemetry (`opentelemetry-javaagent.jar`). Это позволяет без изменения кода собирать:
|
||||
|
||||
118
STARTUP_GUIDE.md
Normal file
118
STARTUP_GUIDE.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Инструкция запуска Magistr
|
||||
|
||||
Ниже — минимальный порядок действий. Подробности по инфраструктуре и секретам находятся в
|
||||
[`docs/INFRASTRUCTURE.md`](docs/INFRASTRUCTURE.md) и
|
||||
[`docs/SECURITY_RUNBOOK.md`](docs/SECURITY_RUNBOOK.md).
|
||||
|
||||
## 1. Локальный запуск
|
||||
|
||||
Нужны Git, Docker Engine, Docker Compose и запущенный Caddy из `../сaddy-proxy/`.
|
||||
|
||||
1. В корне проекта создайте локальный файл настроек:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
openssl rand -base64 36 # значение POSTGRES_PASSWORD
|
||||
openssl rand -base64 48 # значение JWT_SECRET
|
||||
```
|
||||
|
||||
Вставьте полученные значения в `.env`. Файл `.env` не коммитьте.
|
||||
|
||||
2. Один раз создайте общую Docker-сеть, затем запустите Caddy и Magistr:
|
||||
|
||||
```bash
|
||||
docker network inspect proxy >/dev/null 2>&1 || docker network create proxy
|
||||
docker compose -f ../сaddy-proxy/compose.yaml up -d
|
||||
docker compose config --quiet
|
||||
docker compose up -d --build
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
3. Откройте `https://localhost` (`http://localhost` перенаправит на HTTPS). Проверьте backend
|
||||
через Caddy:
|
||||
|
||||
```bash
|
||||
curl -kfsS https://localhost/actuator/health/readiness
|
||||
```
|
||||
|
||||
Если браузер не доверяет локальному сертификату Caddy, на CachyOS/Arch выполните:
|
||||
|
||||
```bash
|
||||
docker cp caddy:/data/caddy/pki/authorities/local/root.crt /tmp/caddy-local-root.crt
|
||||
sudo trust anchor --store /tmp/caddy-local-root.crt
|
||||
```
|
||||
|
||||
До установки проверьте SHA-256 отпечаток командой
|
||||
`openssl x509 -in /tmp/caddy-local-root.crt -noout -fingerprint -sha256`, затем полностью
|
||||
перезапустите браузер.
|
||||
|
||||
Для первого локального входа: `admin` / `admin`. Эти данные предназначены только для
|
||||
разработки.
|
||||
|
||||
4. Если запуск не удался:
|
||||
|
||||
```bash
|
||||
docker compose logs --tail=200 backend db frontend
|
||||
```
|
||||
|
||||
Полный локальный сброс БД выполняется командой `docker compose down -v`, но она удалит
|
||||
все локальные данные. Обычная остановка без удаления данных: `docker compose down`.
|
||||
|
||||
## 2. Первый запуск в production
|
||||
|
||||
1. Подготовьте K3s/Kubernetes-кластер и убедитесь, что `kubectl get nodes` работает.
|
||||
PostgreSQL в текущие манифесты не входит: заранее создайте доступную из кластера БД для
|
||||
каждого университета. Пользователь БД должен иметь права на создание и изменение схемы —
|
||||
Flyway применит `V1__init.sql` автоматически.
|
||||
|
||||
2. Настройте DNS для `magistr.zuev.company` и остальных tenant-доменов на публичный
|
||||
reverse proxy/Ingress. В текущем `../k8s/ingress.yaml` нет секции TLS: для HTTPS нужно
|
||||
настроить внешний Caddy с сертификатом либо добавить cert-manager/TLS в кластер.
|
||||
|
||||
3. Создайте namespace и четыре обязательных Secret по
|
||||
[`docs/SECURITY_RUNBOOK.md`](docs/SECURITY_RUNBOOK.md):
|
||||
|
||||
- `app-secret`: `JWT_SECRET`, `POSTGRES_USER`, `POSTGRES_PASSWORD`;
|
||||
- `tenants-secret`: файл `tenants.json`;
|
||||
- `otel-postgres-secret`: подключения Collector к БД;
|
||||
- `gitea-registry`: доступ к приватным Docker-образам.
|
||||
|
||||
Для `magistr.zuev.company` запись в `tenants.json` должна иметь `"domain": "magistr"`
|
||||
и реальный JDBC URL. Не сохраняйте значения Secret в Git или логах.
|
||||
|
||||
4. В Gitea Actions добавьте секреты `ZUEV_TOKEN` (доступ к registry) и
|
||||
`KUBECONFIG_DATA` (kubeconfig в Base64). Push в `main` соберёт, проверит и опубликует
|
||||
образы. При самом первом запуске автоматический deploy может ещё не найти Deployment —
|
||||
возьмите два опубликованных digest из CI/registry и выполните bootstrap вручную:
|
||||
|
||||
```bash
|
||||
export BACKEND_IMAGE_REF='gitea.zuev.company/zuev/magistr-backend@sha256:<digest>'
|
||||
export FRONTEND_IMAGE_REF='gitea.zuev.company/zuev/magistr-frontend@sha256:<digest>'
|
||||
K8S_DIR=../k8s bash scripts/check-production-secrets.sh
|
||||
K8S_DIR=../k8s bash scripts/check-artifact-pinning.sh
|
||||
kubectl kustomize ../k8s >/dev/null
|
||||
bash ../k8s/deploy.sh apply
|
||||
```
|
||||
|
||||
5. Проверьте результат:
|
||||
|
||||
```bash
|
||||
bash ../k8s/deploy.sh status
|
||||
kubectl rollout status deployment/backend -n magistr --timeout=300s
|
||||
kubectl logs -n magistr -l app=backend --tail=200
|
||||
curl -fsS https://magistr.zuev.company/
|
||||
```
|
||||
|
||||
После первого входа немедленно смените/отключите все тестовые пароли из `V1__init.sql`.
|
||||
Следующие push в `main` CI/CD сможет развёртывать автоматически.
|
||||
|
||||
## 3. Что могу выполнить я
|
||||
|
||||
По вашей команде я могу подготовить `.env`, запустить и диагностировать локальный Compose,
|
||||
проверить/исправить код и манифесты, прогнать тесты и dry-run Kubernetes, а при доступном
|
||||
`kubectl` — проверить состояние кластера.
|
||||
|
||||
Без вас я не могу получить реальные пароли и токены, настроить DNS/сертификаты у внешнего
|
||||
провайдера или войти в Gitea/Kubernetes, если доступы не предоставлены. Production deploy,
|
||||
ротацию Secret и удаление БД я не выполняю без отдельного явного разрешения: это меняет
|
||||
внешнюю систему и может прервать работу либо уничтожить данные.
|
||||
@@ -1,12 +1,20 @@
|
||||
FROM maven:3.9-eclipse-temurin-17 AS build
|
||||
FROM maven:3.9.9-eclipse-temurin-17@sha256:f58d59b6273e785ac0a4477f6e9b5ba1d7731c75b906c0f7b34076f1851318cc AS build
|
||||
WORKDIR /app
|
||||
COPY pom.xml .
|
||||
RUN mvn dependency:go-offline -B
|
||||
COPY src ./src
|
||||
RUN mvn package -DskipTests -B
|
||||
RUN curl -L -o opentelemetry-javaagent.jar https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar
|
||||
|
||||
FROM eclipse-temurin:17-jre-alpine
|
||||
ARG OTEL_JAVAAGENT_VERSION=2.28.1
|
||||
ARG OTEL_JAVAAGENT_SHA256=faa89bdeebf9b1f52be4a4374689176717b02a59df2d8f8b6eb9aa39f9292589
|
||||
RUN mvn org.apache.maven.plugins:maven-dependency-plugin:3.6.1:copy \
|
||||
-Dartifact=io.opentelemetry.javaagent:opentelemetry-javaagent:${OTEL_JAVAAGENT_VERSION} \
|
||||
-DoutputDirectory=/app \
|
||||
-Dmdep.stripVersion=true \
|
||||
-B \
|
||||
&& echo "${OTEL_JAVAAGENT_SHA256} /app/opentelemetry-javaagent.jar" | sha256sum -c -
|
||||
|
||||
FROM eclipse-temurin:17-jre-alpine@sha256:02320dd4ce20e243dfb915c686089cf9315c763084fafbb12d5c9993aee18b57
|
||||
|
||||
# Best practice: run as a non-root user
|
||||
RUN addgroup -S spring && adduser -S spring -G spring
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Стандартные liveness/readiness probes Kubernetes -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JPA + PostgreSQL -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
@@ -76,6 +82,28 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>mockwebserver</artifactId>
|
||||
<version>4.12.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>okhttp-tls</artifactId>
|
||||
<version>4.12.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package com.magistr.app.config;
|
||||
|
||||
import com.magistr.app.config.tenant.TenantConfig;
|
||||
import com.magistr.app.config.tenant.TenantConfigWatcher;
|
||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* При запуске приложения инициализирует БД для каждого тенанта.
|
||||
* Делегирует инициализацию в TenantConfigWatcher.initDatabaseForTenant().
|
||||
*/
|
||||
@Component
|
||||
public class DataInitializer implements CommandLineRunner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
|
||||
|
||||
private final TenantRoutingDataSource routingDataSource;
|
||||
private final TenantConfigWatcher configWatcher;
|
||||
|
||||
public DataInitializer(TenantRoutingDataSource routingDataSource,
|
||||
TenantConfigWatcher configWatcher) {
|
||||
this.routingDataSource = routingDataSource;
|
||||
this.configWatcher = configWatcher;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
log.info("Initializing databases for {} tenant(s)...", routingDataSource.getTenantConfigs().size());
|
||||
|
||||
for (TenantConfig tenant : routingDataSource.getTenantConfigs().values()) {
|
||||
configWatcher.initDatabaseForTenant(tenant);
|
||||
}
|
||||
|
||||
log.info("Database initialization complete");
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ public class AuthorizationInterceptor implements HandlerInterceptor {
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
|
||||
AuthContext.clear();
|
||||
|
||||
if (!request.getRequestURI().startsWith("/api/") || "OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||
return true;
|
||||
}
|
||||
@@ -42,13 +44,13 @@ public class AuthorizationInterceptor implements HandlerInterceptor {
|
||||
return false;
|
||||
}
|
||||
|
||||
AuthContext.setCurrentUser(user);
|
||||
RequireRoles roles = resolveRoles(handler);
|
||||
if (roles != null && Arrays.stream(roles.value()).noneMatch(role -> role == user.role())) {
|
||||
writeError(response, HttpServletResponse.SC_FORBIDDEN, "Недостаточно прав для выполнения операции");
|
||||
return false;
|
||||
}
|
||||
|
||||
AuthContext.setCurrentUser(user);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Использует X-Forwarded-For только когда запрос действительно пришёл от доверенного proxy.
|
||||
*/
|
||||
@Component
|
||||
public class ClientIpResolver {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ClientIpResolver.class);
|
||||
private static final int MAX_FORWARDED_HEADER_LENGTH = 2_048;
|
||||
private static final int MAX_PROXY_HOPS = 20;
|
||||
private static final String UNKNOWN_ADDRESS = "неизвестен";
|
||||
|
||||
private final List<CidrRange> trustedProxyRanges;
|
||||
|
||||
public ClientIpResolver(@Value("${app.http.trusted-proxy-cidrs:}") String trustedProxyCidrs) {
|
||||
this.trustedProxyRanges = parseTrustedRanges(trustedProxyCidrs);
|
||||
}
|
||||
|
||||
public String resolve(HttpServletRequest request) {
|
||||
Optional<InetAddress> remoteAddress = parseAddress(request.getRemoteAddr());
|
||||
String fallback = remoteAddress.map(InetAddress::getHostAddress).orElse(UNKNOWN_ADDRESS);
|
||||
if (remoteAddress.isEmpty() || !isTrusted(remoteAddress.get())) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded == null || forwarded.isBlank()) {
|
||||
return fallback;
|
||||
}
|
||||
if (forwarded.length() > MAX_FORWARDED_HEADER_LENGTH) {
|
||||
log.debug("Заголовок X-Forwarded-For отклонён: превышена допустимая длина");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
String[] hops = forwarded.split(",", -1);
|
||||
if (hops.length > MAX_PROXY_HOPS) {
|
||||
log.debug("Заголовок X-Forwarded-For отклонён: слишком длинная цепочка proxy");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
InetAddress leftmost = null;
|
||||
for (int index = hops.length - 1; index >= 0; index--) {
|
||||
Optional<InetAddress> hop = parseAddress(hops[index].trim());
|
||||
if (hop.isEmpty()) {
|
||||
log.debug("Заголовок X-Forwarded-For отклонён: обнаружен некорректный IP-адрес");
|
||||
return fallback;
|
||||
}
|
||||
leftmost = hop.get();
|
||||
if (!isTrusted(hop.get())) {
|
||||
return hop.get().getHostAddress();
|
||||
}
|
||||
}
|
||||
return leftmost == null ? fallback : leftmost.getHostAddress();
|
||||
}
|
||||
|
||||
private boolean isTrusted(InetAddress address) {
|
||||
return trustedProxyRanges.stream().anyMatch(range -> range.contains(address));
|
||||
}
|
||||
|
||||
private List<CidrRange> parseTrustedRanges(String rawRanges) {
|
||||
if (rawRanges == null || rawRanges.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
List<CidrRange> ranges = new ArrayList<>();
|
||||
for (String rawRange : rawRanges.split(",")) {
|
||||
String value = rawRange.trim();
|
||||
if (!value.isEmpty()) {
|
||||
ranges.add(CidrRange.parse(value));
|
||||
}
|
||||
}
|
||||
return List.copyOf(ranges);
|
||||
}
|
||||
|
||||
private static Optional<InetAddress> parseAddress(String rawAddress) {
|
||||
if (rawAddress == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String value = rawAddress.trim().toLowerCase(Locale.ROOT);
|
||||
if (value.startsWith("[") && value.endsWith("]")) {
|
||||
value = value.substring(1, value.length() - 1);
|
||||
}
|
||||
if (value.isEmpty() || !value.matches("[0-9a-f:.]+")) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(InetAddress.getByName(value));
|
||||
} catch (UnknownHostException ignored) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private record CidrRange(byte[] network, int prefixLength) {
|
||||
|
||||
static CidrRange parse(String value) {
|
||||
String[] parts = value.split("/", -1);
|
||||
Optional<InetAddress> address = ClientIpResolver.parseAddress(parts[0]);
|
||||
if (address.isEmpty()) {
|
||||
throw new IllegalStateException("Некорректный адрес доверенного proxy: " + value);
|
||||
}
|
||||
int addressBits = address.get().getAddress().length * 8;
|
||||
int prefix;
|
||||
try {
|
||||
prefix = parts.length == 1 ? addressBits : Integer.parseInt(parts[1]);
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new IllegalStateException("Некорректная маска доверенного proxy: " + value, exception);
|
||||
}
|
||||
if (parts.length > 2 || prefix < 0 || prefix > addressBits) {
|
||||
throw new IllegalStateException("Некорректная маска доверенного proxy: " + value);
|
||||
}
|
||||
return new CidrRange(mask(address.get().getAddress(), prefix), prefix);
|
||||
}
|
||||
|
||||
boolean contains(InetAddress candidate) {
|
||||
byte[] candidateBytes = candidate.getAddress();
|
||||
return candidateBytes.length == network.length
|
||||
&& Arrays.equals(mask(candidateBytes, prefixLength), network);
|
||||
}
|
||||
|
||||
private static byte[] mask(byte[] address, int prefix) {
|
||||
byte[] masked = Arrays.copyOf(address, address.length);
|
||||
int fullBytes = prefix / 8;
|
||||
int remainingBits = prefix % 8;
|
||||
if (fullBytes < masked.length && remainingBits > 0) {
|
||||
masked[fullBytes] &= (byte) (0xFF << (8 - remainingBits));
|
||||
fullBytes++;
|
||||
}
|
||||
Arrays.fill(masked, fullBytes, masked.length, (byte) 0);
|
||||
return masked;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,34 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "app.jwt")
|
||||
public class JwtProperties {
|
||||
|
||||
private String secret = "dev-only-change-this-jwt-secret-32-bytes-minimum";
|
||||
private static final Set<String> LEGACY_INSECURE_SECRET_FINGERPRINTS = Set.of(
|
||||
"884e52f1fbb3b0caf409bc76a14ba8e601d6117be5a9188319f2e84f9e19a853",
|
||||
"54f5809ada311ba1e12146a14db39ede2082b6f73ca7749bae8cd8450726eaf9"
|
||||
);
|
||||
|
||||
private static final Set<String> PLACEHOLDER_MARKERS = Set.of(
|
||||
"change-me",
|
||||
"change_this",
|
||||
"change-this",
|
||||
"dev-only",
|
||||
"placeholder",
|
||||
"replace-with",
|
||||
"replace_this",
|
||||
"replace-this"
|
||||
);
|
||||
|
||||
private String secret;
|
||||
private Duration accessTtl = Duration.ofMinutes(15);
|
||||
private Duration refreshTtl = Duration.ofDays(7);
|
||||
private String refreshCookieName = "magistr_refresh";
|
||||
@@ -57,10 +78,37 @@ public class JwtProperties {
|
||||
}
|
||||
|
||||
public byte[] secretBytes() {
|
||||
byte[] bytes = secret == null ? new byte[0] : secret.getBytes(StandardCharsets.UTF_8);
|
||||
if (secret == null || secret.isBlank()) {
|
||||
throw new IllegalStateException("JWT_SECRET обязателен и не может быть пустым");
|
||||
}
|
||||
|
||||
byte[] bytes = secret.getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length < 32) {
|
||||
throw new IllegalStateException("JWT_SECRET должен быть не короче 32 байт");
|
||||
}
|
||||
|
||||
String normalized = secret.toLowerCase(Locale.ROOT);
|
||||
if (PLACEHOLDER_MARKERS.stream().anyMatch(normalized::contains)
|
||||
|| LEGACY_INSECURE_SECRET_FINGERPRINTS.contains(sha256(bytes))) {
|
||||
throw new IllegalStateException("JWT_SECRET содержит известное небезопасное или шаблонное значение");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public void validateForEnvironment(boolean production) {
|
||||
secretBytes();
|
||||
if (production && !refreshCookieSecure) {
|
||||
throw new IllegalStateException(
|
||||
"В production-профиле JWT_REFRESH_COOKIE_SECURE должен иметь значение true"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private String sha256(byte[] value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("В JVM недоступен алгоритм SHA-256", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class JwtSecurityStartupValidator {
|
||||
|
||||
private final JwtProperties properties;
|
||||
private final Environment environment;
|
||||
|
||||
public JwtSecurityStartupValidator(JwtProperties properties, Environment environment) {
|
||||
this.properties = properties;
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void validate() {
|
||||
boolean production = environment.acceptsProfiles(Profiles.of("prod", "production"));
|
||||
properties.validateForEnvironment(production);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import com.magistr.app.config.tenant.TenantContext;
|
||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import com.magistr.app.repository.AuthLoginAttemptAuditRepository;
|
||||
import com.magistr.app.repository.AuthLoginRateLimitRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Очищает старый аудит входа и неактивные счётчики отдельно в каждой tenant-БД. */
|
||||
@Component
|
||||
public class LoginAttemptAuditCleanupJob {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LoginAttemptAuditCleanupJob.class);
|
||||
|
||||
private final TenantRoutingDataSource routingDataSource;
|
||||
private final AuthLoginAttemptAuditRepository auditRepository;
|
||||
private final AuthLoginRateLimitRepository rateLimitRepository;
|
||||
private final boolean enabled;
|
||||
private final Duration retention;
|
||||
private final int batchSize;
|
||||
private final int maxBatchesPerTenant;
|
||||
private final Clock clock;
|
||||
private final AtomicBoolean cleanupInProgress = new AtomicBoolean(false);
|
||||
|
||||
@Autowired
|
||||
public LoginAttemptAuditCleanupJob(
|
||||
TenantRoutingDataSource routingDataSource,
|
||||
AuthLoginAttemptAuditRepository auditRepository,
|
||||
AuthLoginRateLimitRepository rateLimitRepository,
|
||||
@Value("${app.login-security.audit-cleanup.enabled:true}") boolean enabled,
|
||||
@Value("${app.login-security.audit-cleanup.retention:90d}") Duration retention,
|
||||
@Value("${app.login-security.audit-cleanup.batch-size:500}") int batchSize,
|
||||
@Value("${app.login-security.audit-cleanup.max-batches-per-tenant:20}") int maxBatchesPerTenant) {
|
||||
this(routingDataSource, auditRepository, rateLimitRepository, enabled, retention,
|
||||
batchSize, maxBatchesPerTenant, Clock.systemUTC());
|
||||
}
|
||||
|
||||
LoginAttemptAuditCleanupJob(TenantRoutingDataSource routingDataSource,
|
||||
AuthLoginAttemptAuditRepository auditRepository,
|
||||
AuthLoginRateLimitRepository rateLimitRepository,
|
||||
boolean enabled,
|
||||
Duration retention,
|
||||
int batchSize,
|
||||
int maxBatchesPerTenant,
|
||||
Clock clock) {
|
||||
this.routingDataSource = Objects.requireNonNull(routingDataSource);
|
||||
this.auditRepository = Objects.requireNonNull(auditRepository);
|
||||
this.rateLimitRepository = Objects.requireNonNull(rateLimitRepository);
|
||||
if (retention == null || retention.isZero() || retention.isNegative()) {
|
||||
throw new IllegalArgumentException("Срок хранения аудита входа должен быть положительным");
|
||||
}
|
||||
if (batchSize < 1 || batchSize > 10_000) {
|
||||
throw new IllegalArgumentException("Размер пачки очистки аудита должен быть от 1 до 10000");
|
||||
}
|
||||
if (maxBatchesPerTenant < 1 || maxBatchesPerTenant > 1_000) {
|
||||
throw new IllegalArgumentException("Число пачек очистки аудита должно быть от 1 до 1000");
|
||||
}
|
||||
this.enabled = enabled;
|
||||
this.retention = retention;
|
||||
this.batchSize = batchSize;
|
||||
this.maxBatchesPerTenant = maxBatchesPerTenant;
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
@Scheduled(
|
||||
fixedDelayString = "${app.login-security.audit-cleanup.interval-ms:86400000}",
|
||||
initialDelayString = "${app.login-security.audit-cleanup.initial-delay-ms:120000}"
|
||||
)
|
||||
public void cleanupScheduled() {
|
||||
if (enabled) {
|
||||
cleanupNow();
|
||||
}
|
||||
}
|
||||
|
||||
public CleanupSummary cleanupNow() {
|
||||
if (!cleanupInProgress.compareAndSet(false, true)) {
|
||||
return new CleanupSummary(0, 0, 0, 0, true);
|
||||
}
|
||||
String previousTenant = TenantContext.getCurrentTenant();
|
||||
try {
|
||||
TenantContext.clear();
|
||||
Map<String, ?> tenants = routingDataSource.snapshotTenantConfigs();
|
||||
Instant now = clock.instant();
|
||||
Instant cutoff = now.minus(retention);
|
||||
int deletedAudits = 0;
|
||||
int deletedLimits = 0;
|
||||
int failedTenants = 0;
|
||||
for (String tenant : tenants.keySet()) {
|
||||
TenantContext.setCurrentTenant(tenant);
|
||||
try {
|
||||
deletedAudits += cleanupAudit(tenant, cutoff);
|
||||
deletedLimits += cleanupLimits(tenant, cutoff, now);
|
||||
} catch (RuntimeException failure) {
|
||||
failedTenants++;
|
||||
log.warn("Очистка аудита входа tenant-БД '{}' завершилась ошибкой: errorType={}",
|
||||
tenant, failure.getClass().getSimpleName());
|
||||
log.debug("Технические детали очистки аудита входа", failure);
|
||||
} finally {
|
||||
TenantContext.clear();
|
||||
}
|
||||
}
|
||||
if (deletedAudits + deletedLimits > 0) {
|
||||
log.info("Очистка аудита входа завершена: tenantCount={}, auditDeleted={}, rateLimitDeleted={}, failedCount={}",
|
||||
tenants.size(), deletedAudits, deletedLimits, failedTenants);
|
||||
}
|
||||
return new CleanupSummary(
|
||||
tenants.size(), deletedAudits, deletedLimits, failedTenants, false
|
||||
);
|
||||
} finally {
|
||||
restoreTenant(previousTenant);
|
||||
cleanupInProgress.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private int cleanupAudit(String tenant, Instant cutoff) {
|
||||
int deletedTotal = 0;
|
||||
for (int batch = 0; batch < maxBatchesPerTenant; batch++) {
|
||||
int deleted = auditRepository.deleteCleanupBatch(cutoff, batchSize);
|
||||
deletedTotal += deleted;
|
||||
if (deleted < batchSize) {
|
||||
return deletedTotal;
|
||||
}
|
||||
}
|
||||
log.warn("Очистка аудита входа tenant-БД '{}' достигла лимита пачек", tenant);
|
||||
return deletedTotal;
|
||||
}
|
||||
|
||||
private int cleanupLimits(String tenant, Instant cutoff, Instant now) {
|
||||
int deletedTotal = 0;
|
||||
for (int batch = 0; batch < maxBatchesPerTenant; batch++) {
|
||||
int deleted = rateLimitRepository.deleteStaleBatch(cutoff, now, batchSize);
|
||||
deletedTotal += deleted;
|
||||
if (deleted < batchSize) {
|
||||
return deletedTotal;
|
||||
}
|
||||
}
|
||||
log.warn("Очистка счётчиков входа tenant-БД '{}' достигла лимита пачек", tenant);
|
||||
return deletedTotal;
|
||||
}
|
||||
|
||||
private void restoreTenant(String tenant) {
|
||||
if (tenant == null || tenant.isBlank()) {
|
||||
TenantContext.clear();
|
||||
} else {
|
||||
TenantContext.setCurrentTenant(tenant);
|
||||
}
|
||||
}
|
||||
|
||||
public record CleanupSummary(int tenantCount,
|
||||
int auditDeletedCount,
|
||||
int rateLimitDeletedCount,
|
||||
int failedTenantCount,
|
||||
boolean skippedBecauseAlreadyRunning) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import com.magistr.app.model.AuthLoginAttemptAudit;
|
||||
import com.magistr.app.model.AuthLoginRateLimit;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.AuthLoginAttemptAuditRepository;
|
||||
import com.magistr.app.repository.AuthLoginRateLimitRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.text.Normalizer;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class LoginRateLimitService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LoginRateLimitService.class);
|
||||
private static final String OUTCOME_FAILURE = "FAILURE";
|
||||
private static final String OUTCOME_BLOCKED = "BLOCKED";
|
||||
|
||||
private final AuthLoginRateLimitRepository rateLimitRepository;
|
||||
private final AuthLoginAttemptAuditRepository auditRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
private final LoginSecurityProperties properties;
|
||||
private final Clock clock;
|
||||
private final String dummyPasswordHash;
|
||||
|
||||
@Autowired
|
||||
public LoginRateLimitService(AuthLoginRateLimitRepository rateLimitRepository,
|
||||
AuthLoginAttemptAuditRepository auditRepository,
|
||||
UserRepository userRepository,
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
LoginSecurityProperties properties) {
|
||||
this(rateLimitRepository, auditRepository, userRepository, passwordEncoder,
|
||||
properties, Clock.systemUTC());
|
||||
}
|
||||
|
||||
LoginRateLimitService(AuthLoginRateLimitRepository rateLimitRepository,
|
||||
AuthLoginAttemptAuditRepository auditRepository,
|
||||
UserRepository userRepository,
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
LoginSecurityProperties properties,
|
||||
Clock clock) {
|
||||
this.rateLimitRepository = Objects.requireNonNull(rateLimitRepository);
|
||||
this.auditRepository = Objects.requireNonNull(auditRepository);
|
||||
this.userRepository = Objects.requireNonNull(userRepository);
|
||||
this.passwordEncoder = Objects.requireNonNull(passwordEncoder);
|
||||
this.properties = Objects.requireNonNull(properties);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.dummyPasswordHash = passwordEncoder.encode("dummy-password-for-timing-only");
|
||||
}
|
||||
|
||||
/**
|
||||
* Блокировка строки удерживается до завершения проверки пароля и записи результата.
|
||||
* Поэтому разные backend-pod не могут одновременно обойти общий счётчик попыток.
|
||||
*/
|
||||
@Transactional
|
||||
public AuthenticationDecision authenticate(String tenant,
|
||||
String username,
|
||||
String rawPassword,
|
||||
String clientIp) {
|
||||
String safeTenant = normalizeAndLimit(tenant, 100, "unknown-tenant");
|
||||
String normalizedUsername = normalizeAndLimit(username, 100, "пустое-имя");
|
||||
String safeClientIp = normalizeAndLimit(clientIp, 64, "неизвестен");
|
||||
Instant now = clock.instant();
|
||||
|
||||
rateLimitRepository.ensureExists(safeTenant, normalizedUsername, safeClientIp, now);
|
||||
AuthLoginRateLimit state = rateLimitRepository
|
||||
.findForUpdate(safeTenant, normalizedUsername, safeClientIp)
|
||||
.orElseThrow(() -> new IllegalStateException("Не удалось заблокировать счётчик попыток входа"));
|
||||
|
||||
if (state.getBlockedUntil() != null && state.getBlockedUntil().isAfter(now)) {
|
||||
long retryAfter = retryAfterSeconds(now, state.getBlockedUntil());
|
||||
audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, now, retryAfter);
|
||||
logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, retryAfter);
|
||||
return AuthenticationDecision.blocked(retryAfter);
|
||||
}
|
||||
|
||||
if (!state.getWindowStartedAt().plus(properties.getAttemptWindow()).isAfter(now)) {
|
||||
state.setFailureCount(0);
|
||||
state.setWindowStartedAt(now);
|
||||
state.setBlockedUntil(null);
|
||||
}
|
||||
|
||||
Optional<User> user = username == null ? Optional.empty() : userRepository.findByUsername(username);
|
||||
String storedHash = user.map(User::getPassword).orElse(dummyPasswordHash);
|
||||
boolean passwordMatches = passwordEncoder.matches(
|
||||
rawPassword == null ? "" : rawPassword,
|
||||
storedHash
|
||||
);
|
||||
boolean authenticated = passwordMatches && user.isPresent() && !user.get().isArchivedRecord();
|
||||
|
||||
if (authenticated) {
|
||||
rateLimitRepository.delete(state);
|
||||
return AuthenticationDecision.authenticated(user.get());
|
||||
}
|
||||
|
||||
int failureCount = state.getFailureCount() + 1;
|
||||
state.setFailureCount(failureCount);
|
||||
state.setLastFailureAt(now);
|
||||
state.setUpdatedAt(now);
|
||||
|
||||
if (failureCount >= properties.getMaxFailures()) {
|
||||
Duration blockDuration = progressiveBlockDuration(failureCount);
|
||||
Instant blockedUntil = now.plus(blockDuration);
|
||||
state.setBlockedUntil(blockedUntil);
|
||||
long retryAfter = retryAfterSeconds(now, blockedUntil);
|
||||
audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, now, retryAfter);
|
||||
logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, retryAfter);
|
||||
return AuthenticationDecision.blocked(retryAfter);
|
||||
}
|
||||
|
||||
audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_FAILURE, now, null);
|
||||
logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_FAILURE, null);
|
||||
return AuthenticationDecision.rejected();
|
||||
}
|
||||
|
||||
private Duration progressiveBlockDuration(int failureCount) {
|
||||
int exponent = Math.min(failureCount - properties.getMaxFailures(), 30);
|
||||
long multiplier = 1L << exponent;
|
||||
Duration candidate;
|
||||
try {
|
||||
candidate = properties.getBaseBlockDuration().multipliedBy(multiplier);
|
||||
} catch (ArithmeticException overflow) {
|
||||
return properties.getMaxBlockDuration();
|
||||
}
|
||||
return candidate.compareTo(properties.getMaxBlockDuration()) > 0
|
||||
? properties.getMaxBlockDuration()
|
||||
: candidate;
|
||||
}
|
||||
|
||||
private void audit(String tenant,
|
||||
String username,
|
||||
String clientIp,
|
||||
String outcome,
|
||||
Instant occurredAt,
|
||||
Long retryAfterSeconds) {
|
||||
Integer retryAfter = retryAfterSeconds == null
|
||||
? null
|
||||
: Math.toIntExact(Math.min(retryAfterSeconds, Integer.MAX_VALUE));
|
||||
auditRepository.save(new AuthLoginAttemptAudit(
|
||||
tenant, username, clientIp, outcome, occurredAt, retryAfter
|
||||
));
|
||||
}
|
||||
|
||||
private void logDenied(String tenant,
|
||||
String username,
|
||||
String clientIp,
|
||||
String outcome,
|
||||
Long retryAfterSeconds) {
|
||||
log.warn(
|
||||
"Неудачная попытка входа: tenant={}, usernameFingerprint={}, clientIp={}, outcome={}, retryAfterSeconds={}",
|
||||
tenant,
|
||||
fingerprint(username),
|
||||
clientIp,
|
||||
outcome,
|
||||
retryAfterSeconds
|
||||
);
|
||||
}
|
||||
|
||||
private String normalizeAndLimit(String value, int maxLength, String fallback) {
|
||||
String bounded = value == null
|
||||
? fallback
|
||||
: value.substring(0, Math.min(value.length(), maxLength * 4));
|
||||
String normalized = Normalizer.normalize(bounded, Normalizer.Form.NFKC)
|
||||
.replaceAll("\\p{C}", "")
|
||||
.strip()
|
||||
.toLowerCase(Locale.ROOT);
|
||||
if (normalized.isBlank()) {
|
||||
normalized = fallback;
|
||||
}
|
||||
return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
|
||||
}
|
||||
|
||||
private long retryAfterSeconds(Instant now, Instant blockedUntil) {
|
||||
Duration remaining = Duration.between(now, blockedUntil);
|
||||
long seconds = remaining.getSeconds() + (remaining.getNano() > 0 ? 1 : 0);
|
||||
return Math.max(1, seconds);
|
||||
}
|
||||
|
||||
private String fingerprint(String value) {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(digest, 0, 8);
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("В JVM недоступен алгоритм SHA-256", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public record AuthenticationDecision(Status status, User user, long retryAfterSeconds) {
|
||||
|
||||
static AuthenticationDecision authenticated(User user) {
|
||||
return new AuthenticationDecision(Status.AUTHENTICATED, user, 0);
|
||||
}
|
||||
|
||||
static AuthenticationDecision rejected() {
|
||||
return new AuthenticationDecision(Status.REJECTED, null, 0);
|
||||
}
|
||||
|
||||
static AuthenticationDecision blocked(long retryAfterSeconds) {
|
||||
return new AuthenticationDecision(Status.BLOCKED, null, retryAfterSeconds);
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
AUTHENTICATED,
|
||||
REJECTED,
|
||||
BLOCKED
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "app.login-security")
|
||||
public class LoginSecurityProperties {
|
||||
|
||||
private int maxFailures = 5;
|
||||
private Duration attemptWindow = Duration.ofMinutes(15);
|
||||
private Duration baseBlockDuration = Duration.ofMinutes(1);
|
||||
private Duration maxBlockDuration = Duration.ofMinutes(15);
|
||||
|
||||
@PostConstruct
|
||||
void validate() {
|
||||
if (maxFailures < 2 || maxFailures > 100) {
|
||||
throw new IllegalStateException("LOGIN_RATE_MAX_FAILURES должен быть от 2 до 100");
|
||||
}
|
||||
requirePositive(attemptWindow, "Окно учёта попыток входа");
|
||||
requirePositive(baseBlockDuration, "Начальный срок блокировки входа");
|
||||
requirePositive(maxBlockDuration, "Максимальный срок блокировки входа");
|
||||
if (maxBlockDuration.compareTo(baseBlockDuration) < 0) {
|
||||
throw new IllegalStateException(
|
||||
"Максимальный срок блокировки входа не может быть меньше начального"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void requirePositive(Duration duration, String fieldName) {
|
||||
if (duration == null || duration.isZero() || duration.isNegative()) {
|
||||
throw new IllegalStateException(fieldName + " должен быть положительным");
|
||||
}
|
||||
}
|
||||
|
||||
public int getMaxFailures() {
|
||||
return maxFailures;
|
||||
}
|
||||
|
||||
public void setMaxFailures(int maxFailures) {
|
||||
this.maxFailures = maxFailures;
|
||||
}
|
||||
|
||||
public Duration getAttemptWindow() {
|
||||
return attemptWindow;
|
||||
}
|
||||
|
||||
public void setAttemptWindow(Duration attemptWindow) {
|
||||
this.attemptWindow = attemptWindow;
|
||||
}
|
||||
|
||||
public Duration getBaseBlockDuration() {
|
||||
return baseBlockDuration;
|
||||
}
|
||||
|
||||
public void setBaseBlockDuration(Duration baseBlockDuration) {
|
||||
this.baseBlockDuration = baseBlockDuration;
|
||||
}
|
||||
|
||||
public Duration getMaxBlockDuration() {
|
||||
return maxBlockDuration;
|
||||
}
|
||||
|
||||
public void setMaxBlockDuration(Duration maxBlockDuration) {
|
||||
this.maxBlockDuration = maxBlockDuration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import com.magistr.app.config.tenant.TenantContext;
|
||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import com.magistr.app.repository.AuthRefreshTokenRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Периодически очищает старые refresh-сессии отдельно в каждой активной tenant-БД.
|
||||
*/
|
||||
@Component
|
||||
public class RefreshTokenCleanupJob {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RefreshTokenCleanupJob.class);
|
||||
|
||||
private final TenantRoutingDataSource routingDataSource;
|
||||
private final AuthRefreshTokenRepository repository;
|
||||
private final boolean enabled;
|
||||
private final Duration retention;
|
||||
private final int batchSize;
|
||||
private final int maxBatchesPerTenant;
|
||||
private final Clock clock;
|
||||
private final AtomicBoolean cleanupInProgress = new AtomicBoolean(false);
|
||||
|
||||
@Autowired
|
||||
public RefreshTokenCleanupJob(
|
||||
TenantRoutingDataSource routingDataSource,
|
||||
AuthRefreshTokenRepository repository,
|
||||
@Value("${app.jwt.refresh-cleanup.enabled:true}") boolean enabled,
|
||||
@Value("${app.jwt.refresh-cleanup.retention:30d}") Duration retention,
|
||||
@Value("${app.jwt.refresh-cleanup.batch-size:500}") int batchSize,
|
||||
@Value("${app.jwt.refresh-cleanup.max-batches-per-tenant:20}") int maxBatchesPerTenant) {
|
||||
this(
|
||||
routingDataSource,
|
||||
repository,
|
||||
enabled,
|
||||
retention,
|
||||
batchSize,
|
||||
maxBatchesPerTenant,
|
||||
Clock.systemUTC()
|
||||
);
|
||||
}
|
||||
|
||||
RefreshTokenCleanupJob(TenantRoutingDataSource routingDataSource,
|
||||
AuthRefreshTokenRepository repository,
|
||||
boolean enabled,
|
||||
Duration retention,
|
||||
int batchSize,
|
||||
int maxBatchesPerTenant,
|
||||
Clock clock) {
|
||||
this.routingDataSource = Objects.requireNonNull(
|
||||
routingDataSource,
|
||||
"Маршрутизатор tenant-БД обязателен"
|
||||
);
|
||||
this.repository = Objects.requireNonNull(repository, "Репозиторий refresh-токенов обязателен");
|
||||
if (retention == null || retention.isZero() || retention.isNegative()) {
|
||||
throw new IllegalArgumentException("Срок хранения refresh-сессий должен быть положительным");
|
||||
}
|
||||
if (batchSize < 1 || batchSize > 10_000) {
|
||||
throw new IllegalArgumentException("Размер пачки очистки должен быть от 1 до 10000");
|
||||
}
|
||||
if (maxBatchesPerTenant < 1 || maxBatchesPerTenant > 1_000) {
|
||||
throw new IllegalArgumentException("Число пачек очистки должно быть от 1 до 1000");
|
||||
}
|
||||
this.enabled = enabled;
|
||||
this.retention = retention;
|
||||
this.batchSize = batchSize;
|
||||
this.maxBatchesPerTenant = maxBatchesPerTenant;
|
||||
this.clock = Objects.requireNonNull(clock, "Часы очистки обязательны");
|
||||
}
|
||||
|
||||
@Scheduled(
|
||||
fixedDelayString = "${app.jwt.refresh-cleanup.interval-ms:3600000}",
|
||||
initialDelayString = "${app.jwt.refresh-cleanup.initial-delay-ms:60000}"
|
||||
)
|
||||
public void cleanupScheduled() {
|
||||
if (enabled) {
|
||||
cleanupNow();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет один ограниченный проход. Локальный guard не допускает наложения запусков
|
||||
* внутри pod, а SQL SKIP LOCKED обеспечивает безопасную совместную работу нескольких pod.
|
||||
*/
|
||||
public CleanupSummary cleanupNow() {
|
||||
if (!cleanupInProgress.compareAndSet(false, true)) {
|
||||
return new CleanupSummary(0, 0, 0, true);
|
||||
}
|
||||
|
||||
String previousTenant = TenantContext.getCurrentTenant();
|
||||
try {
|
||||
TenantContext.clear();
|
||||
Map<String, ?> tenantSnapshot = routingDataSource.snapshotTenantConfigs();
|
||||
Instant cutoff = clock.instant().minus(retention);
|
||||
int deletedTotal = 0;
|
||||
int failedTenants = 0;
|
||||
|
||||
for (String tenant : tenantSnapshot.keySet()) {
|
||||
TenantContext.setCurrentTenant(tenant);
|
||||
try {
|
||||
deletedTotal += cleanupTenant(tenant, cutoff);
|
||||
} catch (RuntimeException cleanupFailure) {
|
||||
failedTenants++;
|
||||
log.warn("Очистка refresh-сессий tenant-БД '{}' завершилась ошибкой: errorType={}",
|
||||
tenant, cleanupFailure.getClass().getSimpleName());
|
||||
log.debug("Технические детали очистки refresh-сессий", cleanupFailure);
|
||||
} finally {
|
||||
TenantContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedTotal > 0) {
|
||||
log.info("Очистка refresh-сессий завершена: tenantCount={}, deletedCount={}, failedCount={}",
|
||||
tenantSnapshot.size(), deletedTotal, failedTenants);
|
||||
}
|
||||
return new CleanupSummary(tenantSnapshot.size(), deletedTotal, failedTenants, false);
|
||||
} finally {
|
||||
restoreTenant(previousTenant);
|
||||
cleanupInProgress.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private int cleanupTenant(String tenant, Instant cutoff) {
|
||||
int deletedTotal = 0;
|
||||
for (int batch = 0; batch < maxBatchesPerTenant; batch++) {
|
||||
int deleted = repository.deleteCleanupBatch(cutoff, batchSize);
|
||||
deletedTotal += deleted;
|
||||
if (deleted < batchSize) {
|
||||
return deletedTotal;
|
||||
}
|
||||
}
|
||||
log.warn("Очистка refresh-сессий tenant-БД '{}' достигла лимита пачек за один проход",
|
||||
tenant);
|
||||
return deletedTotal;
|
||||
}
|
||||
|
||||
private void restoreTenant(String previousTenant) {
|
||||
if (previousTenant == null || previousTenant.isBlank()) {
|
||||
TenantContext.clear();
|
||||
} else {
|
||||
TenantContext.setCurrentTenant(previousTenant);
|
||||
}
|
||||
}
|
||||
|
||||
public record CleanupSummary(int tenantCount,
|
||||
int deletedCount,
|
||||
int failedTenantCount,
|
||||
boolean skippedBecauseAlreadyRunning) {
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,15 @@ import com.magistr.app.model.AuthRefreshToken;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.AuthRefreshTokenRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Optional;
|
||||
@@ -25,17 +27,24 @@ public class RefreshTokenService {
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
private final AuthRefreshTokenRepository repository;
|
||||
private final JwtProperties properties;
|
||||
private final Clock clock;
|
||||
|
||||
@Autowired
|
||||
public RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties) {
|
||||
this(repository, properties, Clock.systemUTC());
|
||||
}
|
||||
|
||||
RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties, Clock clock) {
|
||||
this.repository = repository;
|
||||
this.properties = properties;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public String createSession(User user, String tenant, String userAgent, String ipAddress) {
|
||||
String refreshToken = generateRawToken();
|
||||
AuthRefreshToken token = new AuthRefreshToken();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
Instant now = clock.instant();
|
||||
token.setUser(user);
|
||||
token.setTenant(tenant);
|
||||
token.setTokenHash(hashToken(refreshToken));
|
||||
@@ -53,9 +62,9 @@ public class RefreshTokenService {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
Instant now = clock.instant();
|
||||
String currentHash = hashToken(rawToken);
|
||||
Optional<AuthRefreshToken> storedOpt = repository.findByTokenHash(currentHash);
|
||||
Optional<AuthRefreshToken> storedOpt = repository.findByTokenHashForUpdate(currentHash);
|
||||
if (storedOpt.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
@@ -101,7 +110,7 @@ public class RefreshTokenService {
|
||||
.filter(token -> tenant.equalsIgnoreCase(token.getTenant()))
|
||||
.filter(token -> token.getRevokedAt() == null)
|
||||
.ifPresent(token -> {
|
||||
token.setRevokedAt(LocalDateTime.now());
|
||||
token.setRevokedAt(clock.instant());
|
||||
repository.save(token);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
||||
/**
|
||||
* Обновляет K8s ConfigMap tenants-config через Kubernetes REST API.
|
||||
*
|
||||
* Работает ТОЛЬКО внутри K8s пода (использует ServiceAccount token).
|
||||
* При запуске вне K8s (локальная разработка) — просто логирует предупреждение.
|
||||
*/
|
||||
@Service
|
||||
public class ConfigMapUpdater {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ConfigMapUpdater.class);
|
||||
|
||||
private static final String TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token";
|
||||
private static final String NAMESPACE_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/namespace";
|
||||
private static final String K8S_API_BASE = "https://kubernetes.default.svc";
|
||||
private static final String CONFIGMAP_NAME = "tenants-config";
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final boolean runningInK8s;
|
||||
|
||||
public ConfigMapUpdater() {
|
||||
this.runningInK8s = Files.exists(Path.of(TOKEN_PATH));
|
||||
if (!runningInK8s) {
|
||||
log.info("Приложение запущено вне K8s — обновление ConfigMap будет пропущено");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет ConfigMap tenants-config с новым списком тенантов.
|
||||
* @return true если обновление успешно (или мы не в K8s)
|
||||
*/
|
||||
public boolean updateTenantsConfig(List<TenantConfig> tenants) {
|
||||
if (!runningInK8s) {
|
||||
log.warn("Приложение запущено вне K8s, пропускаем обновление ConfigMap");
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
String token = Files.readString(Path.of(TOKEN_PATH)).trim();
|
||||
String namespace = Files.readString(Path.of(NAMESPACE_PATH)).trim();
|
||||
|
||||
// Формируем JSON для тенантов
|
||||
String tenantsJson = objectMapper.writerWithDefaultPrettyPrinter()
|
||||
.writeValueAsString(tenants);
|
||||
|
||||
// Strategic merge patch для ConfigMap
|
||||
String patchBody = objectMapper.writeValueAsString(Map.of(
|
||||
"data", Map.of("tenants.json", tenantsJson)
|
||||
));
|
||||
|
||||
String url = String.format("%s/api/v1/namespaces/%s/configmaps/%s",
|
||||
K8S_API_BASE, namespace, CONFIGMAP_NAME);
|
||||
|
||||
// Создаём HttpClient с отключённой проверкой сертификатов
|
||||
// (внутри кластера используется self-signed CA)
|
||||
HttpClient client = createInsecureClient();
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.header("Content-Type", "application/strategic-merge-patch+json")
|
||||
.method("PATCH", HttpRequest.BodyPublishers.ofString(patchBody))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() == 200) {
|
||||
log.info("ConfigMap '{}' успешно обновлён, тенантов: {}", CONFIGMAP_NAME, tenants.size());
|
||||
return true;
|
||||
} else {
|
||||
log.error("Не удалось обновить ConfigMap: HTTP {} — {}", response.statusCode(), response.body());
|
||||
return false;
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Ошибка при обновлении ConfigMap: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт HttpClient, который доверяет self-signed сертификатам K8s API.
|
||||
*/
|
||||
private HttpClient createInsecureClient() {
|
||||
try {
|
||||
TrustManager[] trustAll = new TrustManager[]{
|
||||
new X509TrustManager() {
|
||||
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
|
||||
public void checkClientTrusted(X509Certificate[] certs, String authType) {}
|
||||
public void checkServerTrusted(X509Certificate[] certs, String authType) {}
|
||||
}
|
||||
};
|
||||
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(null, trustAll, new SecureRandom());
|
||||
|
||||
return HttpClient.newBuilder()
|
||||
.sslContext(sslContext)
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
log.warn("Не удалось создать клиент без проверки сертификата, используем стандартный: {}", e.getMessage());
|
||||
return HttpClient.newHttpClient();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,792 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLParameters;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyStore;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Атомарно изменяет Kubernetes Secret с конфигурацией тенантов через Kubernetes REST API.
|
||||
*
|
||||
* <p>Каждая мутация сначала читает актуальный Secret, применяет изменение к полученному
|
||||
* списку и отправляет полный объект обратно условным {@code PUT} с
|
||||
* {@code metadata.resourceVersion}. При конфликте операция повторно читает актуальное
|
||||
* состояние и заново применяет только свою мутацию.</p>
|
||||
*/
|
||||
@Service
|
||||
public class KubernetesTenantSecretUpdater implements TenantConfigStore {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(KubernetesTenantSecretUpdater.class);
|
||||
|
||||
private static final Path DEFAULT_TOKEN_PATH =
|
||||
Path.of("/var/run/secrets/kubernetes.io/serviceaccount/token");
|
||||
private static final Path DEFAULT_NAMESPACE_PATH =
|
||||
Path.of("/var/run/secrets/kubernetes.io/serviceaccount/namespace");
|
||||
private static final Path DEFAULT_CA_PATH =
|
||||
Path.of("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt");
|
||||
private static final String DEFAULT_API_BASE = "https://kubernetes.default.svc";
|
||||
private static final String DEFAULT_SECRET_NAME = "tenants-secret";
|
||||
private static final int DEFAULT_MAX_ATTEMPTS = 3;
|
||||
private static final Duration DEFAULT_RETRY_DELAY = Duration.ofMillis(50);
|
||||
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10);
|
||||
private static final Pattern DOMAIN_PATTERN = Pattern.compile(
|
||||
"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
|
||||
);
|
||||
private static final TypeReference<List<TenantConfig>> TENANT_LIST_TYPE = new TypeReference<>() {
|
||||
};
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Path tokenPath;
|
||||
private final Path namespacePath;
|
||||
private final Path caPath;
|
||||
private final String apiBase;
|
||||
private final String secretName;
|
||||
private final boolean runningInKubernetes;
|
||||
private final int maxAttempts;
|
||||
private final Duration retryDelay;
|
||||
|
||||
private volatile HttpClient httpClient;
|
||||
|
||||
public KubernetesTenantSecretUpdater() {
|
||||
this(
|
||||
DEFAULT_TOKEN_PATH,
|
||||
DEFAULT_NAMESPACE_PATH,
|
||||
DEFAULT_CA_PATH,
|
||||
DEFAULT_API_BASE,
|
||||
DEFAULT_SECRET_NAME,
|
||||
new ObjectMapper()
|
||||
);
|
||||
}
|
||||
|
||||
KubernetesTenantSecretUpdater(Path tokenPath,
|
||||
Path namespacePath,
|
||||
Path caPath,
|
||||
String apiBase,
|
||||
String secretName,
|
||||
ObjectMapper objectMapper) {
|
||||
this(
|
||||
tokenPath,
|
||||
namespacePath,
|
||||
caPath,
|
||||
apiBase,
|
||||
secretName,
|
||||
objectMapper,
|
||||
DEFAULT_MAX_ATTEMPTS,
|
||||
DEFAULT_RETRY_DELAY
|
||||
);
|
||||
}
|
||||
|
||||
KubernetesTenantSecretUpdater(Path tokenPath,
|
||||
Path namespacePath,
|
||||
Path caPath,
|
||||
String apiBase,
|
||||
String secretName,
|
||||
ObjectMapper objectMapper,
|
||||
int maxAttempts,
|
||||
Duration retryDelay) {
|
||||
this(
|
||||
tokenPath,
|
||||
namespacePath,
|
||||
caPath,
|
||||
apiBase,
|
||||
secretName,
|
||||
objectMapper,
|
||||
maxAttempts,
|
||||
retryDelay,
|
||||
detectKubernetesEnvironment(tokenPath)
|
||||
);
|
||||
}
|
||||
|
||||
KubernetesTenantSecretUpdater(Path tokenPath,
|
||||
Path namespacePath,
|
||||
Path caPath,
|
||||
String apiBase,
|
||||
String secretName,
|
||||
ObjectMapper objectMapper,
|
||||
int maxAttempts,
|
||||
Duration retryDelay,
|
||||
boolean kubernetesEnvironment) {
|
||||
this.tokenPath = Objects.requireNonNull(tokenPath, "Путь к ServiceAccount token не задан");
|
||||
this.namespacePath = Objects.requireNonNull(namespacePath, "Путь к namespace не задан");
|
||||
this.caPath = Objects.requireNonNull(caPath, "Путь к ServiceAccount CA не задан");
|
||||
this.apiBase = normalizeApiBase(apiBase);
|
||||
this.secretName = requireText(secretName, "Имя tenant Secret не задано");
|
||||
this.objectMapper = Objects.requireNonNull(objectMapper, "ObjectMapper не задан");
|
||||
if (maxAttempts < 1) {
|
||||
throw new IllegalArgumentException("Количество попыток должно быть положительным");
|
||||
}
|
||||
if (retryDelay == null || retryDelay.isNegative()) {
|
||||
throw new IllegalArgumentException("Задержка повтора не может быть отрицательной");
|
||||
}
|
||||
this.maxAttempts = maxAttempts;
|
||||
this.retryDelay = retryDelay;
|
||||
this.runningInKubernetes = kubernetesEnvironment || Files.exists(tokenPath);
|
||||
|
||||
if (!runningInKubernetes) {
|
||||
log.info("Приложение запущено вне Kubernetes — постоянное изменение tenant Secret отключено");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public TenantSecretUpdateReceipt upsertTenant(TenantConfig tenant) {
|
||||
TenantConfig requested = TenantSecretUpdateReceipt.copyOf(
|
||||
Objects.requireNonNull(tenant, "Конфигурация тенанта не задана")
|
||||
);
|
||||
String domain = normalizeDomain(requested.getDomain());
|
||||
requested.setDomain(domain);
|
||||
|
||||
if (!runningInKubernetes) {
|
||||
log.warn("Приложение запущено вне Kubernetes, постоянное добавление тенанта пропущено");
|
||||
return new TenantSecretUpdateReceipt(false, false, null, List.of(), List.of(requested));
|
||||
}
|
||||
|
||||
return mutate(tenants -> {
|
||||
List<TenantConfig> updated = new ArrayList<>(tenants);
|
||||
updated.removeIf(existing -> domain.equals(existing.getDomain()));
|
||||
updated.add(requested);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public TenantSecretUpdateReceipt removeTenant(String domain) {
|
||||
String normalizedDomain = normalizeDomain(domain);
|
||||
if (!runningInKubernetes) {
|
||||
log.warn("Приложение запущено вне Kubernetes, постоянное удаление тенанта пропущено");
|
||||
return new TenantSecretUpdateReceipt(false, false, null, List.of(), List.of());
|
||||
}
|
||||
|
||||
return mutate(tenants -> {
|
||||
List<TenantConfig> updated = new ArrayList<>(tenants);
|
||||
updated.removeIf(existing -> normalizedDomain.equals(existing.getDomain()));
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public TenantSecretCompensationResult compensate(TenantSecretUpdateReceipt receipt) {
|
||||
Objects.requireNonNull(receipt, "Квитанция изменения tenant Secret не задана");
|
||||
if (!receipt.persisted() || !receipt.changed()) {
|
||||
return TenantSecretCompensationResult.NOT_REQUIRED;
|
||||
}
|
||||
if (!runningInKubernetes) {
|
||||
return TenantSecretCompensationResult.NOT_REQUIRED;
|
||||
}
|
||||
|
||||
String committedVersion = requireText(
|
||||
receipt.committedResourceVersion(),
|
||||
"В квитанции отсутствует resourceVersion сохранённого tenant Secret"
|
||||
);
|
||||
RequestContext context = requestContext();
|
||||
TenantConfigPersistenceException lastFailure = null;
|
||||
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
CurrentSecret current;
|
||||
try {
|
||||
current = readCurrent(context);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new TenantConfigPersistenceException("Компенсация tenant Secret прервана", e);
|
||||
} catch (IOException e) {
|
||||
lastFailure = persistenceFailure("Не удалось прочитать tenant Secret для компенсации", e);
|
||||
if (!prepareRetry(attempt, "чтение перед компенсацией")) {
|
||||
throw lastFailure;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!committedVersion.equals(current.resourceVersion())) {
|
||||
log.warn("Компенсация tenant Secret пропущена: после исходной операции Secret уже изменён");
|
||||
return TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE;
|
||||
}
|
||||
if (!sameTenants(current.tenants(), receipt.committedTenants())) {
|
||||
throw new TenantConfigPersistenceException(
|
||||
"Содержимое tenant Secret не соответствует resourceVersion из квитанции"
|
||||
);
|
||||
}
|
||||
|
||||
HttpResponse<String> response;
|
||||
try {
|
||||
response = putSecret(context, current, receipt.previousTenants());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new TenantConfigPersistenceException("Компенсация tenant Secret прервана", e);
|
||||
} catch (IOException e) {
|
||||
TenantSecretCompensationResult reconciled = reconcileCompensation(context, receipt);
|
||||
if (reconciled != null) {
|
||||
return reconciled;
|
||||
}
|
||||
lastFailure = persistenceFailure("Не удалось подтвердить компенсацию tenant Secret", e);
|
||||
if (!prepareRetry(attempt, "компенсация после сетевой ошибки")) {
|
||||
throw lastFailure;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSuccess(response.statusCode())) {
|
||||
TenantSecretCompensationResult confirmed = confirmCompensation(
|
||||
context,
|
||||
receipt,
|
||||
response.body()
|
||||
);
|
||||
if (confirmed != null) {
|
||||
if (confirmed == TenantSecretCompensationResult.RESTORED) {
|
||||
log.info("Конфигурация tenant Secret восстановлена: tenantCount={}",
|
||||
receipt.previousTenants().size());
|
||||
}
|
||||
return confirmed;
|
||||
}
|
||||
lastFailure = new TenantConfigPersistenceException(
|
||||
"Kubernetes API не подтвердил содержимое после компенсации tenant Secret"
|
||||
);
|
||||
if (!prepareRetry(attempt, "проверка компенсации tenant Secret")) {
|
||||
throw lastFailure;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (response.statusCode() == 409) {
|
||||
log.warn("Компенсация tenant Secret пропущена: обнаружено конкурентное изменение");
|
||||
return TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE;
|
||||
}
|
||||
if (isRetryable(response.statusCode())) {
|
||||
TenantSecretCompensationResult reconciled = reconcileCompensation(context, receipt);
|
||||
if (reconciled != null) {
|
||||
return reconciled;
|
||||
}
|
||||
lastFailure = new TenantConfigPersistenceException(
|
||||
"Kubernetes API временно отклонил компенсацию tenant Secret: HTTP "
|
||||
+ response.statusCode()
|
||||
);
|
||||
if (!prepareRetry(attempt, "компенсация tenant Secret")) {
|
||||
throw lastFailure;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
throw new TenantConfigPersistenceException(
|
||||
"Kubernetes API отклонил компенсацию tenant Secret: HTTP " + response.statusCode()
|
||||
);
|
||||
}
|
||||
|
||||
throw lastFailure == null
|
||||
? new TenantConfigPersistenceException("Не удалось компенсировать tenant Secret")
|
||||
: lastFailure;
|
||||
}
|
||||
|
||||
private TenantSecretUpdateReceipt mutate(Function<List<TenantConfig>, List<TenantConfig>> mutation) {
|
||||
RequestContext context = requestContext();
|
||||
TenantConfigPersistenceException lastFailure = null;
|
||||
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
CurrentSecret current;
|
||||
try {
|
||||
current = readCurrent(context);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new TenantConfigPersistenceException("Изменение tenant Secret прервано", e);
|
||||
} catch (IOException e) {
|
||||
lastFailure = persistenceFailure("Не удалось прочитать актуальный tenant Secret", e);
|
||||
if (!prepareRetry(attempt, "чтение tenant Secret")) {
|
||||
throw lastFailure;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
List<TenantConfig> previous = normalizeTenants(current.tenants());
|
||||
List<TenantConfig> committed = normalizeTenants(mutation.apply(previous));
|
||||
if (sameTenants(previous, committed)) {
|
||||
return new TenantSecretUpdateReceipt(
|
||||
true,
|
||||
false,
|
||||
current.resourceVersion(),
|
||||
previous,
|
||||
committed
|
||||
);
|
||||
}
|
||||
|
||||
HttpResponse<String> response;
|
||||
try {
|
||||
response = putSecret(context, current, committed);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new TenantConfigPersistenceException("Изменение tenant Secret прервано", e);
|
||||
} catch (IOException e) {
|
||||
TenantSecretUpdateReceipt reconciled = reconcileMutation(context, previous, committed);
|
||||
if (reconciled != null) {
|
||||
return reconciled;
|
||||
}
|
||||
lastFailure = persistenceFailure("Не удалось подтвердить изменение tenant Secret", e);
|
||||
if (!prepareRetry(attempt, "изменение после сетевой ошибки")) {
|
||||
throw lastFailure;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSuccess(response.statusCode())) {
|
||||
TenantSecretUpdateReceipt confirmed = confirmMutation(
|
||||
context,
|
||||
current,
|
||||
previous,
|
||||
committed,
|
||||
response.body()
|
||||
);
|
||||
if (confirmed != null) {
|
||||
log.info("Tenant Secret атомарно изменён: tenantCount={}", committed.size());
|
||||
return confirmed;
|
||||
}
|
||||
throw new TenantConfigPersistenceException(
|
||||
"Kubernetes API не подтвердил сохранённое содержимое tenant Secret"
|
||||
);
|
||||
}
|
||||
|
||||
if (response.statusCode() == 409) {
|
||||
lastFailure = new TenantConfigPersistenceException(
|
||||
"Исчерпаны попытки изменения tenant Secret из-за конкурентных обновлений"
|
||||
);
|
||||
if (!prepareRetry(attempt, "конфликт resourceVersion")) {
|
||||
throw lastFailure;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isRetryable(response.statusCode())) {
|
||||
TenantSecretUpdateReceipt reconciled = reconcileMutation(context, previous, committed);
|
||||
if (reconciled != null) {
|
||||
return reconciled;
|
||||
}
|
||||
lastFailure = new TenantConfigPersistenceException(
|
||||
"Kubernetes API временно отклонил изменение tenant Secret: HTTP "
|
||||
+ response.statusCode()
|
||||
);
|
||||
if (!prepareRetry(attempt, "изменение tenant Secret")) {
|
||||
throw lastFailure;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new TenantConfigPersistenceException(
|
||||
"Kubernetes API отклонил изменение tenant Secret: HTTP " + response.statusCode()
|
||||
);
|
||||
}
|
||||
|
||||
throw lastFailure == null
|
||||
? new TenantConfigPersistenceException("Не удалось изменить tenant Secret")
|
||||
: lastFailure;
|
||||
}
|
||||
|
||||
private CurrentSecret readCurrent(RequestContext context) throws IOException, InterruptedException {
|
||||
HttpRequest request = requestBuilder(context)
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = secureClient().send(
|
||||
request,
|
||||
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)
|
||||
);
|
||||
if (response.statusCode() == 200) {
|
||||
return parseSecret(response.body());
|
||||
}
|
||||
if (isRetryable(response.statusCode())) {
|
||||
throw new RetryableApiException(
|
||||
"Kubernetes API временно не вернул tenant Secret: HTTP " + response.statusCode()
|
||||
);
|
||||
}
|
||||
throw new TenantConfigPersistenceException(
|
||||
"Kubernetes API не вернул tenant Secret: HTTP " + response.statusCode()
|
||||
);
|
||||
}
|
||||
|
||||
private HttpResponse<String> putSecret(RequestContext context,
|
||||
CurrentSecret current,
|
||||
List<TenantConfig> tenants)
|
||||
throws IOException, InterruptedException {
|
||||
ObjectNode body = current.secret().deepCopy();
|
||||
ObjectNode metadata = objectNode(body, "metadata");
|
||||
metadata.put("resourceVersion", current.resourceVersion());
|
||||
ObjectNode data = objectNode(body, "data");
|
||||
data.put("tenants.json", encodeTenants(tenants));
|
||||
|
||||
HttpRequest request = requestBuilder(context)
|
||||
.header("Content-Type", "application/json")
|
||||
.PUT(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
|
||||
.build();
|
||||
return secureClient().send(
|
||||
request,
|
||||
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)
|
||||
);
|
||||
}
|
||||
|
||||
private TenantSecretUpdateReceipt reconcileMutation(RequestContext context,
|
||||
List<TenantConfig> previous,
|
||||
List<TenantConfig> committed) {
|
||||
try {
|
||||
CurrentSecret observed = readCurrent(context);
|
||||
if (sameTenants(observed.tenants(), committed)) {
|
||||
log.info("Изменение tenant Secret подтверждено повторным чтением: tenantCount={}",
|
||||
committed.size());
|
||||
return new TenantSecretUpdateReceipt(
|
||||
true,
|
||||
false,
|
||||
observed.resourceVersion(),
|
||||
previous,
|
||||
observed.tenants()
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new TenantConfigPersistenceException("Проверка изменения tenant Secret прервана", e);
|
||||
} catch (IOException e) {
|
||||
log.warn("Не удалось подтвердить состояние tenant Secret повторным чтением");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private TenantSecretUpdateReceipt confirmMutation(RequestContext context,
|
||||
CurrentSecret previousSecret,
|
||||
List<TenantConfig> previous,
|
||||
List<TenantConfig> committed,
|
||||
String responseBody) {
|
||||
try {
|
||||
CurrentSecret confirmed = parseSecret(responseBody);
|
||||
if (sameTenants(confirmed.tenants(), committed)
|
||||
&& !Objects.equals(confirmed.resourceVersion(), previousSecret.resourceVersion())) {
|
||||
return new TenantSecretUpdateReceipt(
|
||||
true,
|
||||
true,
|
||||
confirmed.resourceVersion(),
|
||||
previous,
|
||||
confirmed.tenants()
|
||||
);
|
||||
}
|
||||
log.warn("Ответ Kubernetes API после изменения tenant Secret не совпал с ожидаемым состоянием");
|
||||
} catch (TenantConfigPersistenceException invalidResponse) {
|
||||
log.warn("Kubernetes API вернул неполный ответ после изменения tenant Secret, выполняется повторное чтение");
|
||||
log.debug("Технические детали неполного ответа tenant Secret", invalidResponse);
|
||||
}
|
||||
return reconcileMutation(context, previous, committed);
|
||||
}
|
||||
|
||||
private TenantSecretCompensationResult confirmCompensation(RequestContext context,
|
||||
TenantSecretUpdateReceipt receipt,
|
||||
String responseBody) {
|
||||
try {
|
||||
CurrentSecret confirmed = parseSecret(responseBody);
|
||||
if (sameTenants(confirmed.tenants(), receipt.previousTenants())) {
|
||||
return TenantSecretCompensationResult.RESTORED;
|
||||
}
|
||||
log.warn("Ответ Kubernetes API после компенсации tenant Secret не совпал с ожидаемым состоянием");
|
||||
} catch (TenantConfigPersistenceException invalidResponse) {
|
||||
log.warn("Kubernetes API вернул неполный ответ после компенсации tenant Secret, выполняется повторное чтение");
|
||||
log.debug("Технические детали неполного ответа компенсации tenant Secret", invalidResponse);
|
||||
}
|
||||
return reconcileCompensation(context, receipt);
|
||||
}
|
||||
|
||||
private TenantSecretCompensationResult reconcileCompensation(RequestContext context,
|
||||
TenantSecretUpdateReceipt receipt) {
|
||||
try {
|
||||
CurrentSecret observed = readCurrent(context);
|
||||
if (sameTenants(observed.tenants(), receipt.previousTenants())) {
|
||||
log.info("Компенсация tenant Secret подтверждена повторным чтением");
|
||||
return TenantSecretCompensationResult.RESTORED;
|
||||
}
|
||||
if (!Objects.equals(observed.resourceVersion(), receipt.committedResourceVersion())) {
|
||||
log.warn("Компенсация tenant Secret остановлена из-за конкурентного изменения");
|
||||
return TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE;
|
||||
}
|
||||
return null;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new TenantConfigPersistenceException("Проверка компенсации tenant Secret прервана", e);
|
||||
} catch (IOException e) {
|
||||
log.warn("Не удалось подтвердить компенсацию tenant Secret повторным чтением");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private CurrentSecret parseSecret(String responseBody) {
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(responseBody);
|
||||
if (!(root instanceof ObjectNode secret)) {
|
||||
throw new TenantConfigPersistenceException("Kubernetes API вернул некорректный tenant Secret");
|
||||
}
|
||||
String resourceVersion = requireText(
|
||||
root.path("metadata").path("resourceVersion").asText(null),
|
||||
"В tenant Secret отсутствует metadata.resourceVersion"
|
||||
);
|
||||
JsonNode data = root.path("data");
|
||||
if (!data.isObject() || !data.hasNonNull("tenants.json")) {
|
||||
throw new TenantConfigPersistenceException(
|
||||
"В tenant Secret отсутствует обязательный ключ data.tenants.json"
|
||||
);
|
||||
}
|
||||
String encoded = requireText(
|
||||
data.path("tenants.json").asText(null),
|
||||
"В tenant Secret ключ data.tenants.json не содержит конфигурацию"
|
||||
);
|
||||
byte[] decoded = Base64.getDecoder().decode(encoded);
|
||||
List<TenantConfig> tenants = objectMapper.readValue(decoded, TENANT_LIST_TYPE);
|
||||
return new CurrentSecret(secret, resourceVersion, normalizeTenants(tenants));
|
||||
} catch (TenantConfigPersistenceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new TenantConfigPersistenceException(
|
||||
"Не удалось разобрать конфигурацию из tenant Secret",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private List<TenantConfig> normalizeTenants(List<TenantConfig> tenants) {
|
||||
if (tenants == null) {
|
||||
throw new TenantConfigPersistenceException("Список конфигураций в tenant Secret не задан");
|
||||
}
|
||||
List<TenantConfig> normalized = new ArrayList<>(tenants.size());
|
||||
Set<String> domains = new HashSet<>();
|
||||
for (TenantConfig source : tenants) {
|
||||
TenantConfig tenant = TenantSecretUpdateReceipt.copyOf(
|
||||
Objects.requireNonNull(source, "В tenant Secret обнаружена пустая конфигурация")
|
||||
);
|
||||
String domain = normalizeDomain(tenant.getDomain());
|
||||
tenant.setDomain(domain);
|
||||
if (!domains.add(domain)) {
|
||||
throw new TenantConfigPersistenceException(
|
||||
"В tenant Secret обнаружены повторяющиеся домены"
|
||||
);
|
||||
}
|
||||
normalized.add(tenant);
|
||||
}
|
||||
normalized.sort(Comparator.comparing(TenantConfig::getDomain));
|
||||
return List.copyOf(normalized);
|
||||
}
|
||||
|
||||
private boolean sameTenants(List<TenantConfig> first, List<TenantConfig> second) {
|
||||
List<TenantConfig> left = normalizeTenants(first);
|
||||
List<TenantConfig> right = normalizeTenants(second);
|
||||
if (left.size() != right.size()) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < left.size(); i++) {
|
||||
if (!sameTenant(left.get(i), right.get(i))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean sameTenant(TenantConfig first, TenantConfig second) {
|
||||
return Objects.equals(first.getName(), second.getName())
|
||||
&& Objects.equals(first.getDomain(), second.getDomain())
|
||||
&& Objects.equals(first.getUrl(), second.getUrl())
|
||||
&& Objects.equals(first.getUsername(), second.getUsername())
|
||||
&& Objects.equals(first.getPassword(), second.getPassword());
|
||||
}
|
||||
|
||||
private RequestContext requestContext() {
|
||||
try {
|
||||
String token = requireText(
|
||||
Files.readString(tokenPath).trim(),
|
||||
"ServiceAccount token не настроен"
|
||||
);
|
||||
String namespace = requireText(
|
||||
Files.readString(namespacePath).trim(),
|
||||
"ServiceAccount namespace не настроен"
|
||||
);
|
||||
URI uri = URI.create(String.format(
|
||||
"%s/api/v1/namespaces/%s/secrets/%s",
|
||||
apiBase,
|
||||
namespace,
|
||||
secretName
|
||||
));
|
||||
return new RequestContext(token, uri);
|
||||
} catch (TenantConfigPersistenceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new TenantConfigPersistenceException(
|
||||
"Не удалось прочитать параметры ServiceAccount для tenant Secret",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpRequest.Builder requestBuilder(RequestContext context) {
|
||||
return HttpRequest.newBuilder()
|
||||
.uri(context.uri())
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.header("Authorization", "Bearer " + context.token())
|
||||
.header("Accept", "application/json");
|
||||
}
|
||||
|
||||
private synchronized HttpClient secureClient() {
|
||||
if (httpClient == null) {
|
||||
try {
|
||||
httpClient = createSecureClient(caPath);
|
||||
} catch (Exception e) {
|
||||
throw new TenantConfigPersistenceException(
|
||||
"Не удалось настроить защищённое соединение с Kubernetes API",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
return httpClient;
|
||||
}
|
||||
|
||||
HttpClient createSecureClient(Path serviceAccountCaPath) throws Exception {
|
||||
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
|
||||
Collection<? extends Certificate> certificates;
|
||||
try (InputStream input = Files.newInputStream(serviceAccountCaPath)) {
|
||||
certificates = certificateFactory.generateCertificates(input);
|
||||
}
|
||||
if (certificates.isEmpty()) {
|
||||
throw new IllegalStateException("ServiceAccount CA не содержит сертификатов");
|
||||
}
|
||||
|
||||
KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
trustStore.load(null, null);
|
||||
int index = 0;
|
||||
for (Certificate certificate : certificates) {
|
||||
trustStore.setCertificateEntry("kubernetes-ca-" + index++, certificate);
|
||||
}
|
||||
|
||||
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(
|
||||
TrustManagerFactory.getDefaultAlgorithm()
|
||||
);
|
||||
trustManagerFactory.init(trustStore);
|
||||
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(null, trustManagerFactory.getTrustManagers(), null);
|
||||
|
||||
SSLParameters sslParameters = new SSLParameters();
|
||||
sslParameters.setEndpointIdentificationAlgorithm("HTTPS");
|
||||
|
||||
return HttpClient.newBuilder()
|
||||
.connectTimeout(CONNECT_TIMEOUT)
|
||||
.sslContext(sslContext)
|
||||
.sslParameters(sslParameters)
|
||||
.build();
|
||||
}
|
||||
|
||||
private boolean prepareRetry(int attempt, String operation) {
|
||||
if (attempt >= maxAttempts) {
|
||||
return false;
|
||||
}
|
||||
log.warn("Временная ошибка tenant Secret, операция будет повторена: операция={}, попытка={}/{}",
|
||||
operation,
|
||||
attempt,
|
||||
maxAttempts);
|
||||
try {
|
||||
long delayMillis = Math.multiplyExact(retryDelay.toMillis(), attempt);
|
||||
if (delayMillis > 0) {
|
||||
Thread.sleep(delayMillis);
|
||||
}
|
||||
return true;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new TenantConfigPersistenceException("Ожидание повтора tenant Secret прервано", e);
|
||||
} catch (ArithmeticException e) {
|
||||
throw new TenantConfigPersistenceException("Некорректная задержка повтора tenant Secret", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String encodeTenants(List<TenantConfig> tenants) throws IOException {
|
||||
String json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(tenants);
|
||||
return Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private ObjectNode objectNode(ObjectNode parent, String fieldName) {
|
||||
JsonNode existing = parent.get(fieldName);
|
||||
if (existing instanceof ObjectNode objectNode) {
|
||||
return objectNode;
|
||||
}
|
||||
ObjectNode created = objectMapper.createObjectNode();
|
||||
parent.set(fieldName, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private static boolean isSuccess(int statusCode) {
|
||||
return statusCode >= 200 && statusCode < 300;
|
||||
}
|
||||
|
||||
private static boolean isRetryable(int statusCode) {
|
||||
return statusCode == 408 || statusCode == 409 || statusCode == 429 || statusCode >= 500;
|
||||
}
|
||||
|
||||
private static boolean detectKubernetesEnvironment(Path tokenPath) {
|
||||
String kubernetesServiceHost = System.getenv("KUBERNETES_SERVICE_HOST");
|
||||
return (tokenPath != null && Files.exists(tokenPath))
|
||||
|| (kubernetesServiceHost != null && !kubernetesServiceHost.isBlank());
|
||||
}
|
||||
|
||||
private static String requireText(String value, String message) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new TenantConfigPersistenceException(message);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static String normalizeDomain(String value) {
|
||||
String domain = requireText(value, "Домен тенанта не задан").toLowerCase(Locale.ROOT);
|
||||
if (!DOMAIN_PATTERN.matcher(domain).matches()) {
|
||||
throw new TenantConfigPersistenceException("Домен тенанта содержит недопустимые символы");
|
||||
}
|
||||
return domain;
|
||||
}
|
||||
|
||||
private static String normalizeApiBase(String apiBase) {
|
||||
String normalized = requireText(apiBase, "Адрес Kubernetes API не задан");
|
||||
return normalized.endsWith("/")
|
||||
? normalized.substring(0, normalized.length() - 1)
|
||||
: normalized;
|
||||
}
|
||||
|
||||
private static TenantConfigPersistenceException persistenceFailure(String message, Exception cause) {
|
||||
return cause instanceof TenantConfigPersistenceException persistenceException
|
||||
? persistenceException
|
||||
: new TenantConfigPersistenceException(message, cause);
|
||||
}
|
||||
|
||||
private record RequestContext(String token, URI uri) {
|
||||
}
|
||||
|
||||
private record CurrentSecret(ObjectNode secret,
|
||||
String resourceVersion,
|
||||
List<TenantConfig> tenants) {
|
||||
}
|
||||
|
||||
private static final class RetryableApiException extends IOException {
|
||||
private RetryableApiException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
/**
|
||||
* Ошибка безопасного чтения или изменения постоянной конфигурации тенантов.
|
||||
*/
|
||||
public class TenantConfigPersistenceException extends RuntimeException {
|
||||
|
||||
public TenantConfigPersistenceException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public TenantConfigPersistenceException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
/**
|
||||
* Хранилище конфигурации тенантов с защитой от потерянных конкурентных обновлений.
|
||||
*/
|
||||
public interface TenantConfigStore {
|
||||
|
||||
TenantSecretUpdateReceipt upsertTenant(TenantConfig tenant);
|
||||
|
||||
TenantSecretUpdateReceipt removeTenant(String domain);
|
||||
|
||||
TenantSecretCompensationResult compensate(TenantSecretUpdateReceipt receipt);
|
||||
}
|
||||
@@ -2,89 +2,208 @@ package com.magistr.app.config.tenant;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
|
||||
import com.magistr.app.service.TenantLifecycleException;
|
||||
import com.magistr.app.service.TenantLifecycleService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/**
|
||||
* Периодически перечитывает tenants.json (mounted ConfigMap).
|
||||
* Если ConfigMap был обновлён через K8s API, этот компонент
|
||||
* подхватит изменения и синхронизирует in-memory datasource'ы.
|
||||
*
|
||||
* Также отвечает за инициализацию БД (init.sql) для новых тенантов.
|
||||
* Периодически применяет конфигурацию тенантов из смонтированного tenants.json.
|
||||
* Все чтения и lifecycle-изменения сериализуются одним monitor'ом сервиса.
|
||||
*/
|
||||
@Component
|
||||
public class TenantConfigWatcher {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantConfigWatcher.class);
|
||||
private static final String MISSING_FAILURE_KEY = "missing";
|
||||
private static final String READ_FAILURE_KEY = "read";
|
||||
|
||||
private final TenantRoutingDataSource routingDataSource;
|
||||
private final DataSource dataSource;
|
||||
static final long RETRY_BASE_DELAY_MILLIS = 30_000L;
|
||||
static final long RETRY_MAX_DELAY_MILLIS = 300_000L;
|
||||
|
||||
private final TenantLifecycleService tenantLifecycleService;
|
||||
private final TenantReadinessRegistry readinessRegistry;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Value("${app.tenants.config-path:tenants.json}")
|
||||
private String tenantsConfigPath;
|
||||
|
||||
// Хеш последнего прочитанного конфига — чтобы не перезагружать зря
|
||||
private String lastConfigHash = "";
|
||||
@Value("${app.tenants.config-required:false}")
|
||||
private boolean tenantsConfigRequired;
|
||||
|
||||
public TenantConfigWatcher(TenantRoutingDataSource routingDataSource, DataSource dataSource) {
|
||||
this.routingDataSource = routingDataSource;
|
||||
this.dataSource = dataSource;
|
||||
}
|
||||
/** Хеш означает только полностью применённое содержимое файла. */
|
||||
private String lastConfigHash = "";
|
||||
private ConfigSnapshot lastAppliedSnapshot;
|
||||
|
||||
private String failedRevisionKey = "";
|
||||
private int consecutiveFailures;
|
||||
private long retryNotBeforeMillis;
|
||||
|
||||
/**
|
||||
* Каждые 30 секунд проверяет, изменился ли tenants.json.
|
||||
* Fence хранит семантические снимки, а не байтовые представления JSON.
|
||||
* Поэтому форматирование файла не может ошибочно снять или установить fence.
|
||||
*/
|
||||
private ConfigSnapshot expectedCommittedSnapshot;
|
||||
private final Set<ConfigSnapshot> deferredSnapshots = new LinkedHashSet<>();
|
||||
|
||||
private boolean configurationUnavailable;
|
||||
private LongSupplier currentTimeMillis = System::currentTimeMillis;
|
||||
|
||||
public TenantConfigWatcher(TenantLifecycleService tenantLifecycleService,
|
||||
TenantReadinessRegistry readinessRegistry) {
|
||||
this.tenantLifecycleService = tenantLifecycleService;
|
||||
this.readinessRegistry = readinessRegistry;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 30_000, initialDelay = 30_000)
|
||||
public void watchForChanges() {
|
||||
try {
|
||||
File file = new File(tenantsConfigPath);
|
||||
if (!file.exists()) return;
|
||||
|
||||
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||
String hash = configHash(content);
|
||||
|
||||
if (hash.equals(lastConfigHash)) {
|
||||
return; // Ничего не изменилось
|
||||
tenantLifecycleService.executeSerialized(this::watchForChangesSerialized);
|
||||
}
|
||||
|
||||
log.info("Обнаружено изменение tenants.json (хеш: {} -> {}), перечитываем конфиг", lastConfigHash, hash);
|
||||
lastConfigHash = hash;
|
||||
private void watchForChangesSerialized() {
|
||||
long now = currentTimeMillis.getAsLong();
|
||||
Path path = Path.of(tenantsConfigPath);
|
||||
if (!Files.exists(path)) {
|
||||
if (tenantsConfigRequired) {
|
||||
handleRequiredMissingFile(now);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {});
|
||||
syncTenants(newTenants);
|
||||
if (isRetryDeferred(READ_FAILURE_KEY, now)) {
|
||||
logDeferredRetry(now);
|
||||
return;
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Ошибка при проверке конфига тенантов: {}", e.getMessage());
|
||||
String failureKey = READ_FAILURE_KEY;
|
||||
try {
|
||||
String content = Files.readString(path, StandardCharsets.UTF_8);
|
||||
String hash = configHash(content);
|
||||
failureKey = hash;
|
||||
Projection projection = parseProjection(content, hash);
|
||||
|
||||
// Даже неизменившееся содержимое сначала разбирается: fence никогда не
|
||||
// принимает решение по сырым байтам до проверки структуры JSON.
|
||||
if (hash.equals(lastConfigHash) && !configurationUnavailable) {
|
||||
clearFenceWhenExpectedSnapshotIsAlreadyApplied(projection.snapshot());
|
||||
return;
|
||||
}
|
||||
if (isRetryDeferred(hash, now)) {
|
||||
logDeferredRetry(now);
|
||||
return;
|
||||
}
|
||||
if (!hash.equals(failedRevisionKey)) {
|
||||
resetRetryState();
|
||||
}
|
||||
if (shouldDefer(projection.snapshot())) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Обнаружено изменение tenants.json, выполняется синхронизация конфигурации тенантов");
|
||||
syncTenants(projection.tenants());
|
||||
markApplied(projection);
|
||||
} catch (Exception failure) {
|
||||
recordFailure(failureKey, failure);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет хеш конфига (вызывается после ручного обновления ConfigMap с этого же пода).
|
||||
* Перед API-мутацией приводит runtime к актуальной безопасной проекции файла.
|
||||
* Ошибка чтения или синхронизации не скрывается: API-операция не должна начинаться
|
||||
* с неизвестного baseline.
|
||||
*/
|
||||
public void refreshHash() {
|
||||
public void prepareForApiMutation() {
|
||||
tenantLifecycleService.executeSerialized(this::prepareForApiMutationSerialized);
|
||||
}
|
||||
|
||||
private void prepareForApiMutationSerialized() {
|
||||
Path path = Path.of(tenantsConfigPath);
|
||||
if (!Files.exists(path)) {
|
||||
if (!tenantsConfigRequired) {
|
||||
return;
|
||||
}
|
||||
TenantLifecycleException failure = new TenantLifecycleException(
|
||||
"Обязательный файл конфигурации тенантов недоступен"
|
||||
);
|
||||
recordFailure(MISSING_FAILURE_KEY, failure);
|
||||
throw failure;
|
||||
}
|
||||
|
||||
String failureKey = READ_FAILURE_KEY;
|
||||
try {
|
||||
File file = new File(tenantsConfigPath);
|
||||
if (file.exists()) {
|
||||
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||
lastConfigHash = configHash(content);
|
||||
String content = Files.readString(path, StandardCharsets.UTF_8);
|
||||
String hash = configHash(content);
|
||||
failureKey = hash;
|
||||
Projection projection = parseProjection(content, hash);
|
||||
|
||||
if (hash.equals(lastConfigHash) && !configurationUnavailable) {
|
||||
clearFenceWhenExpectedSnapshotIsAlreadyApplied(projection.snapshot());
|
||||
return;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Не удалось обновить хеш конфига тенантов: {}", e.getMessage());
|
||||
if (shouldDefer(projection.snapshot())) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncTenants(projection.tenants());
|
||||
markApplied(projection);
|
||||
} catch (Exception failure) {
|
||||
recordFailure(failureKey, failure);
|
||||
if (failure instanceof TenantLifecycleException lifecycleFailure) {
|
||||
throw lifecycleFailure;
|
||||
}
|
||||
throw new TenantLifecycleException(
|
||||
"Не удалось подготовить актуальную конфигурацию тенантов перед изменением",
|
||||
failure
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Устанавливает snapshot fence только после подтверждённой записи в общее
|
||||
* хранилище. Локальные и no-op операции не создают ложных ожиданий от mount.
|
||||
*/
|
||||
public void registerSuccessfulMutation(TenantSecretUpdateReceipt receipt) {
|
||||
Objects.requireNonNull(receipt, "Квитанция изменения tenant Secret не задана");
|
||||
tenantLifecycleService.executeSerialized(() -> registerSuccessfulMutationSerialized(receipt));
|
||||
}
|
||||
|
||||
private void registerSuccessfulMutationSerialized(TenantSecretUpdateReceipt receipt) {
|
||||
if (!receipt.persisted() || !receipt.changed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ConfigSnapshot previous = snapshotOf(receipt.previousTenants());
|
||||
ConfigSnapshot committed = snapshotOf(receipt.committedTenants());
|
||||
|
||||
if (lastAppliedSnapshot != null) {
|
||||
deferredSnapshots.add(lastAppliedSnapshot);
|
||||
}
|
||||
if (expectedCommittedSnapshot != null) {
|
||||
deferredSnapshots.add(expectedCommittedSnapshot);
|
||||
}
|
||||
deferredSnapshots.add(previous);
|
||||
deferredSnapshots.remove(committed);
|
||||
expectedCommittedSnapshot = committed;
|
||||
}
|
||||
|
||||
static String configHash(String content) {
|
||||
@@ -96,67 +215,158 @@ public class TenantConfigWatcher {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизирует in-memory тенантов с конфигом из файла.
|
||||
*/
|
||||
private void syncTenants(List<TenantConfig> newTenants) {
|
||||
Map<String, TenantConfig> current = routingDataSource.getTenantConfigs();
|
||||
Set<String> newDomains = newTenants.stream()
|
||||
.map(t -> t.getDomain().toLowerCase())
|
||||
.collect(Collectors.toSet());
|
||||
private Projection parseProjection(String content, String hash) throws IOException {
|
||||
List<TenantConfig> tenants = objectMapper.readValue(content, new TypeReference<>() { });
|
||||
if (tenants == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"Корневое значение конфигурации тенантов должно быть массивом"
|
||||
);
|
||||
}
|
||||
if (tenantsConfigRequired && tenants.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Обязательная конфигурация тенантов не может быть пустой"
|
||||
);
|
||||
}
|
||||
return new Projection(hash, List.copyOf(tenants), snapshotOf(tenants));
|
||||
}
|
||||
|
||||
// Добавить новые тенанты
|
||||
for (TenantConfig tenant : newTenants) {
|
||||
String domain = tenant.getDomain().toLowerCase();
|
||||
if (!current.containsKey(domain)) {
|
||||
log.info("Добавляем нового тенанта '{}' из обновлённого ConfigMap", domain);
|
||||
routingDataSource.addTenant(tenant);
|
||||
// Инициализируем БД для нового тенанта
|
||||
initDatabaseForTenant(tenant);
|
||||
private ConfigSnapshot snapshotOf(List<TenantConfig> tenants) {
|
||||
Objects.requireNonNull(tenants, "Список конфигураций тенантов не задан");
|
||||
List<NormalizedTenantConfig> normalized = new ArrayList<>(tenants.size());
|
||||
for (TenantConfig tenant : tenants) {
|
||||
Objects.requireNonNull(tenant, "Конфигурация тенанта не задана");
|
||||
String domain = normalizeDomain(tenant.getDomain());
|
||||
String name = tenant.getName() == null || tenant.getName().isBlank()
|
||||
? domain
|
||||
: tenant.getName().trim();
|
||||
String url = tenant.getUrl() == null ? null : tenant.getUrl().trim();
|
||||
normalized.add(new NormalizedTenantConfig(
|
||||
name,
|
||||
domain,
|
||||
url,
|
||||
tenant.getUsername(),
|
||||
tenant.getPassword()
|
||||
));
|
||||
}
|
||||
normalized.sort(NormalizedTenantConfig.ORDER);
|
||||
return new ConfigSnapshot(normalized);
|
||||
}
|
||||
|
||||
private String normalizeDomain(String domain) {
|
||||
return domain == null ? null : domain.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private void syncTenants(List<TenantConfig> tenants) {
|
||||
tenantLifecycleService.synchronizeFromPersistedConfig(tenants);
|
||||
}
|
||||
|
||||
private boolean shouldDefer(ConfigSnapshot snapshot) {
|
||||
return expectedCommittedSnapshot != null && deferredSnapshots.contains(snapshot);
|
||||
}
|
||||
|
||||
private void clearFenceWhenExpectedSnapshotIsAlreadyApplied(ConfigSnapshot snapshot) {
|
||||
if (snapshot.equals(expectedCommittedSnapshot)) {
|
||||
clearSnapshotFence();
|
||||
}
|
||||
}
|
||||
|
||||
// Удалить тенанты, которых больше нет в конфиге
|
||||
for (String existingDomain : new ArrayList<>(current.keySet())) {
|
||||
if (!newDomains.contains(existingDomain)) {
|
||||
log.info("Удаляем тенанта '{}' — его больше нет в ConfigMap", existingDomain);
|
||||
routingDataSource.removeTenant(existingDomain);
|
||||
}
|
||||
}
|
||||
private void markApplied(Projection projection) {
|
||||
lastConfigHash = projection.hash();
|
||||
lastAppliedSnapshot = projection.snapshot();
|
||||
configurationUnavailable = false;
|
||||
resetRetryState();
|
||||
clearSnapshotFence();
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет миграции Flyway для конкретного тенанта пи подключении.
|
||||
* Если БД уже существует, но история Flyway пуста —
|
||||
* делает baseline (считает V1_init.sql уже выполненным).
|
||||
*/
|
||||
public void initDatabaseForTenant(TenantConfig tenant) {
|
||||
String domain = tenant.getDomain();
|
||||
try {
|
||||
TenantContext.setCurrentTenant(domain);
|
||||
|
||||
log.info("[{}] Запускаем миграции Flyway", domain);
|
||||
|
||||
// Получаем DataSource конкретно для этого тенанта
|
||||
javax.sql.DataSource tenantDs = routingDataSource.getResolvedDataSources().get(domain);
|
||||
if (tenantDs == null) {
|
||||
// Если ещё не resolve'нулся (первый запуск), берём обёртку
|
||||
tenantDs = dataSource;
|
||||
private void handleRequiredMissingFile(long now) {
|
||||
if (isRetryDeferred(MISSING_FAILURE_KEY, now)) {
|
||||
logDeferredRetry(now);
|
||||
return;
|
||||
}
|
||||
recordFailure(
|
||||
MISSING_FAILURE_KEY,
|
||||
new TenantLifecycleException("Обязательный файл конфигурации тенантов недоступен")
|
||||
);
|
||||
}
|
||||
|
||||
org.flywaydb.core.Flyway flyway = org.flywaydb.core.Flyway.configure()
|
||||
.dataSource(tenantDs)
|
||||
.baselineOnMigrate(true)
|
||||
.baselineVersion("1")
|
||||
.load();
|
||||
|
||||
flyway.migrate();
|
||||
log.info("[{}] Миграции Flyway успешно выполнены", domain);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[{}] Ошибка миграции Flyway: {}", domain, e.getMessage());
|
||||
} finally {
|
||||
TenantContext.clear();
|
||||
private boolean isRetryDeferred(String failureKey, long now) {
|
||||
return failureKey.equals(failedRevisionKey) && now < retryNotBeforeMillis;
|
||||
}
|
||||
|
||||
private void logDeferredRetry(long now) {
|
||||
log.debug("Повторная синхронизация tenants.json отложена ещё на {} мс",
|
||||
Math.max(0L, retryNotBeforeMillis - now));
|
||||
}
|
||||
|
||||
private void recordFailure(String failureKey, Exception failure) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
configurationUnavailable = true;
|
||||
long retryDelay = registerFailure(failureKey);
|
||||
log.error("Ошибка синхронизации конфигурации тенантов: попытка={}, "
|
||||
+ "следующий повтор не ранее чем через {} мс, errorType={}",
|
||||
consecutiveFailures,
|
||||
retryDelay,
|
||||
failure.getClass().getSimpleName());
|
||||
log.debug("Технические детали синхронизации конфигурации тенантов", failure);
|
||||
}
|
||||
|
||||
private long registerFailure(String failureKey) {
|
||||
if (failureKey.equals(failedRevisionKey) && consecutiveFailures > 0) {
|
||||
consecutiveFailures = Math.min(consecutiveFailures + 1, 31);
|
||||
} else {
|
||||
failedRevisionKey = failureKey;
|
||||
consecutiveFailures = 1;
|
||||
}
|
||||
long delay = retryDelayMillis(consecutiveFailures);
|
||||
retryNotBeforeMillis = safeAdd(currentTimeMillis.getAsLong(), delay);
|
||||
return delay;
|
||||
}
|
||||
|
||||
static long retryDelayMillis(int failureCount) {
|
||||
long delay = RETRY_BASE_DELAY_MILLIS;
|
||||
for (int attempt = 1; attempt < failureCount && delay < RETRY_MAX_DELAY_MILLIS; attempt++) {
|
||||
delay = Math.min(delay * 2, RETRY_MAX_DELAY_MILLIS);
|
||||
}
|
||||
return delay;
|
||||
}
|
||||
|
||||
private long safeAdd(long value, long increment) {
|
||||
return value > Long.MAX_VALUE - increment ? Long.MAX_VALUE : value + increment;
|
||||
}
|
||||
|
||||
private void resetRetryState() {
|
||||
failedRevisionKey = "";
|
||||
consecutiveFailures = 0;
|
||||
retryNotBeforeMillis = 0L;
|
||||
}
|
||||
|
||||
private void clearSnapshotFence() {
|
||||
expectedCommittedSnapshot = null;
|
||||
deferredSnapshots.clear();
|
||||
}
|
||||
|
||||
private record Projection(String hash, List<TenantConfig> tenants, ConfigSnapshot snapshot) {
|
||||
}
|
||||
|
||||
private record ConfigSnapshot(List<NormalizedTenantConfig> tenants) {
|
||||
private ConfigSnapshot {
|
||||
tenants = List.copyOf(tenants);
|
||||
}
|
||||
}
|
||||
|
||||
private record NormalizedTenantConfig(
|
||||
String name,
|
||||
String domain,
|
||||
String url,
|
||||
String username,
|
||||
String password
|
||||
) {
|
||||
private static final Comparator<String> NULL_SAFE_TEXT = Comparator.nullsFirst(String::compareTo);
|
||||
private static final Comparator<NormalizedTenantConfig> ORDER =
|
||||
Comparator.comparing(NormalizedTenantConfig::domain, NULL_SAFE_TEXT)
|
||||
.thenComparing(NormalizedTenantConfig::name, NULL_SAFE_TEXT)
|
||||
.thenComparing(NormalizedTenantConfig::url, NULL_SAFE_TEXT)
|
||||
.thenComparing(NormalizedTenantConfig::username, NULL_SAFE_TEXT)
|
||||
.thenComparing(NormalizedTenantConfig::password, NULL_SAFE_TEXT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package com.magistr.app.config.tenant;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
|
||||
import com.magistr.app.service.TenantDatabaseMigrationService;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -16,12 +19,12 @@ import org.springframework.transaction.PlatformTransactionManager;
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import javax.sql.DataSource;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.sql.Connection;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Конфигурация мультитенантного DataSource.
|
||||
* Загружает тенанты из JSON-файла (mounted ConfigMap).
|
||||
* Загружает тенанты из JSON-файла (смонтированный Kubernetes Secret).
|
||||
*
|
||||
* Если нет ни одного настроенного тенанта — создаёт H2 in-memory БД
|
||||
* как заглушку, чтобы Spring JPA мог инициализироваться.
|
||||
@@ -34,6 +37,9 @@ public class TenantDataSourceConfig {
|
||||
@Value("${app.tenants.config-path:tenants.json}")
|
||||
private String tenantsConfigPath;
|
||||
|
||||
@Value("${app.tenants.config-required:false}")
|
||||
private boolean tenantsConfigRequired;
|
||||
|
||||
@Value("${spring.datasource.url:}")
|
||||
private String defaultDbUrl;
|
||||
|
||||
@@ -45,11 +51,13 @@ public class TenantDataSourceConfig {
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
public DataSource dataSource() {
|
||||
public DataSource dataSource(TenantDatabaseMigrationService migrationService,
|
||||
TenantReadinessRegistry readinessRegistry) {
|
||||
TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource();
|
||||
|
||||
// Загружаем тенантов из JSON (read-only ConfigMap mount)
|
||||
List<TenantConfig> tenants = loadTenantsFromFile();
|
||||
// Загружаем тенантов из JSON (read-only Secret mount)
|
||||
TenantConfigLoadResult loadResult = loadTenantsFromFile();
|
||||
List<TenantConfig> tenants = new ArrayList<>(loadResult.tenants());
|
||||
|
||||
// Если нет тенантов и есть дефолтный datasource — создаём "default" тенант
|
||||
if (tenants.isEmpty() && defaultDbUrl != null && !defaultDbUrl.isBlank()) {
|
||||
@@ -57,22 +65,24 @@ public class TenantDataSourceConfig {
|
||||
"Default", "default", defaultDbUrl, defaultDbUsername, defaultDbPassword
|
||||
);
|
||||
tenants.add(defaultTenant);
|
||||
log.info("No tenants config found, using default datasource: {}", defaultDbUrl);
|
||||
log.info("Конфигурация тенантов отсутствует, используется источник данных по умолчанию");
|
||||
}
|
||||
|
||||
readinessRegistry.clearConfigurationFailure();
|
||||
readinessRegistry.replaceDesired(tenants);
|
||||
if (loadResult.failed() || (tenantsConfigRequired && loadResult.tenants().isEmpty())) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
}
|
||||
|
||||
// Регистрируем тенантов
|
||||
for (TenantConfig tenant : tenants) {
|
||||
try {
|
||||
routingDataSource.addTenant(tenant);
|
||||
} catch (Exception e) {
|
||||
log.error("Не удалось добавить тенанта '{}': {}", tenant.getDomain(), e.getMessage());
|
||||
}
|
||||
registerPreparedTenant(routingDataSource, migrationService, readinessRegistry, tenant, true);
|
||||
}
|
||||
|
||||
// Если всё ещё нет ни одного тенанта — H2 in-memory заглушка
|
||||
// Если всё ещё нет ни одного тенанта — H2-заглушка в памяти
|
||||
if (routingDataSource.getTenantConfigs().isEmpty()) {
|
||||
log.warn("=== НЕТ НАСТРОЕННЫХ ТЕНАНТОВ ===");
|
||||
log.warn("Создаём H2 in-memory заглушку для запуска приложения.");
|
||||
log.warn("Создаём H2-заглушку в памяти для запуска приложения");
|
||||
log.warn("Добавьте тенант через POST /api/database/tenants");
|
||||
|
||||
TenantConfig h2Fallback = new TenantConfig(
|
||||
@@ -80,7 +90,15 @@ public class TenantDataSourceConfig {
|
||||
"jdbc:h2:mem:placeholder;DB_CLOSE_DELAY=-1",
|
||||
"sa", ""
|
||||
);
|
||||
routingDataSource.addTenant(h2Fallback);
|
||||
if (!registerPreparedTenant(
|
||||
routingDataSource,
|
||||
migrationService,
|
||||
readinessRegistry,
|
||||
h2Fallback,
|
||||
false
|
||||
)) {
|
||||
throw new IllegalStateException("Не удалось создать резервный H2 DataSource");
|
||||
}
|
||||
}
|
||||
|
||||
return routingDataSource;
|
||||
@@ -117,21 +135,89 @@ public class TenantDataSourceConfig {
|
||||
return new JpaTransactionManager(emf);
|
||||
}
|
||||
|
||||
private List<TenantConfig> loadTenantsFromFile() {
|
||||
private TenantConfigLoadResult loadTenantsFromFile() {
|
||||
File file = new File(tenantsConfigPath);
|
||||
if (!file.exists()) {
|
||||
log.info("Tenants config file not found: {}", tenantsConfigPath);
|
||||
return new ArrayList<>();
|
||||
if (tenantsConfigRequired) {
|
||||
log.error("Обязательный файл конфигурации тенантов не найден");
|
||||
} else {
|
||||
log.info("Файл конфигурации тенантов не найден");
|
||||
}
|
||||
return new TenantConfigLoadResult(List.of(), tenantsConfigRequired);
|
||||
}
|
||||
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
List<TenantConfig> list = mapper.readValue(file, new TypeReference<>() {});
|
||||
log.info("Loaded {} tenant(s) from {}", list.size(), tenantsConfigPath);
|
||||
return list;
|
||||
} catch (IOException e) {
|
||||
log.error("Не удалось прочитать конфиг тенантов: {}", e.getMessage());
|
||||
return new ArrayList<>();
|
||||
if (list == null) {
|
||||
throw new IllegalArgumentException("Корневое значение конфигурации тенантов должно быть массивом");
|
||||
}
|
||||
log.info("Загружено конфигураций тенантов: {}", list.size());
|
||||
return new TenantConfigLoadResult(List.copyOf(list), false);
|
||||
} catch (Exception e) {
|
||||
log.error("Не удалось прочитать конфигурацию тенантов: типОшибки={}",
|
||||
e.getClass().getSimpleName());
|
||||
log.debug("Технические детали чтения конфигурации тенантов", e);
|
||||
return new TenantConfigLoadResult(List.of(), true);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean registerPreparedTenant(TenantRoutingDataSource routingDataSource,
|
||||
TenantDatabaseMigrationService migrationService,
|
||||
TenantReadinessRegistry readinessRegistry,
|
||||
TenantConfig tenant,
|
||||
boolean requiredForReadiness) {
|
||||
HikariDataSource candidate = null;
|
||||
boolean activated = false;
|
||||
if (requiredForReadiness) {
|
||||
readinessRegistry.markMigrationStarted(tenant);
|
||||
}
|
||||
try {
|
||||
candidate = routingDataSource.prepareTenantDataSource(tenant);
|
||||
try (Connection connection = candidate.getConnection()) {
|
||||
if (!connection.isValid(5)) {
|
||||
throw new IllegalStateException("База данных не подтвердила готовность подключения");
|
||||
}
|
||||
}
|
||||
if (!tenant.getUrl().trim().startsWith("jdbc:h2:")) {
|
||||
migrationService.migrate(candidate);
|
||||
}
|
||||
TenantRoutingDataSource.TenantState previous = routingDataSource.swapTenant(tenant, candidate);
|
||||
activated = true;
|
||||
if (requiredForReadiness) {
|
||||
readinessRegistry.markMigrationSucceeded(tenant);
|
||||
readinessRegistry.markConnectivity(tenant.getDomain(), true);
|
||||
}
|
||||
closeDataSource(previous == null ? null : previous.dataSource());
|
||||
log.info("БД тенанта '{}' проверена и активирована", tenant.getDomain());
|
||||
return true;
|
||||
} catch (Exception startupFailure) {
|
||||
if (requiredForReadiness) {
|
||||
readinessRegistry.markMigrationFailed(tenant);
|
||||
}
|
||||
log.error("Не удалось безопасно активировать БД тенанта '{}': типОшибки={}",
|
||||
tenant.getDomain(), startupFailure.getClass().getSimpleName());
|
||||
log.debug("Технические детали жизненного цикла запуска БД тенанта", startupFailure);
|
||||
return false;
|
||||
} finally {
|
||||
if (!activated) {
|
||||
closeDataSource(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void closeDataSource(DataSource dataSource) {
|
||||
if (dataSource instanceof HikariDataSource hikariDataSource) {
|
||||
try {
|
||||
hikariDataSource.close();
|
||||
} catch (RuntimeException closeFailure) {
|
||||
log.warn("Не удалось штатно закрыть стартовый пул Hikari: типОшибки={}",
|
||||
closeFailure.getClass().getSimpleName());
|
||||
log.debug("Технические детали закрытия стартового пула Hikari", closeFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record TenantConfigLoadResult(List<TenantConfig> tenants, boolean failed) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,13 +52,13 @@ public class TenantInterceptor implements HandlerInterceptor {
|
||||
TenantContext.setCurrentTenant(tenant);
|
||||
MDC.put("tenant.id", tenant);
|
||||
Span.current().setAttribute("tenant.id", tenant);
|
||||
log.debug("Database API request, tenant '{}' (no strict check)", tenant);
|
||||
log.debug("Запрос API управления БД: тенант='{}', строгая проверка отключена", tenant);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Проверяем, существует ли тенант
|
||||
if (routingDataSource != null && !routingDataSource.hasTenant(tenant)) {
|
||||
log.warn("Unknown tenant '{}' from Host '{}' — returning 404", tenant, host);
|
||||
log.warn("Неизвестный тенант '{}' для заголовка Host '{}': возвращается 404", tenant, host);
|
||||
response.setStatus(404);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
new ObjectMapper().writeValue(response.getOutputStream(), Map.of(
|
||||
@@ -72,7 +72,7 @@ public class TenantInterceptor implements HandlerInterceptor {
|
||||
TenantContext.setCurrentTenant(tenant);
|
||||
MDC.put("tenant.id", tenant);
|
||||
Span.current().setAttribute("tenant.id", tenant);
|
||||
log.debug("Resolved tenant '{}' from Host '{}'", tenant, host);
|
||||
log.debug("Тенант '{}' определён по заголовку Host '{}'", tenant, host);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
265
backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java
Executable file → Normal file
265
backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java
Executable file → Normal file
@@ -8,143 +8,252 @@ import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* DataSource, который переключается между БД разных тенантов.
|
||||
* На каждый запрос determineCurrentLookupKey() возвращает текущий тенант из TenantContext.
|
||||
* Runtime-состояние публикуется одним неизменяемым snapshot, поэтому запрос
|
||||
* никогда не видит конфигурацию и DataSource из разных версий.
|
||||
*/
|
||||
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantRoutingDataSource.class);
|
||||
|
||||
private final Map<String, TenantConfig> tenantConfigs = new ConcurrentHashMap<>();
|
||||
private final Map<Object, Object> dataSources = new ConcurrentHashMap<>();
|
||||
private boolean initialized = false;
|
||||
private final Object lifecycleMonitor = new Object();
|
||||
private volatile Map<String, TenantState> tenantStates = Map.of();
|
||||
|
||||
public TenantRoutingDataSource() {
|
||||
// Устанавливаем пустой map чтобы afterPropertiesSet не падал
|
||||
setTargetDataSources(new HashMap<>());
|
||||
// Inherited lifecycle Spring требует initial target map. Runtime routing
|
||||
// выполняется переопределённым determineTargetDataSource().
|
||||
setTargetDataSources(Map.of());
|
||||
setLenientFallback(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object determineCurrentLookupKey() {
|
||||
String tenant = TenantContext.getCurrentTenant();
|
||||
|
||||
if (tenant == null) {
|
||||
// Нет HTTP контекста (JPA init, background tasks) — берём первый доступный
|
||||
if (!dataSources.isEmpty()) {
|
||||
return dataSources.keySet().iterator().next().toString();
|
||||
}
|
||||
return "default";
|
||||
return determineLookupKey(tenantStates);
|
||||
}
|
||||
|
||||
// HTTP запрос — возвращаем точный ключ тенанта
|
||||
// Если тенанта нет — TenantInterceptor уже вернул 404
|
||||
return tenant;
|
||||
@Override
|
||||
protected DataSource determineTargetDataSource() {
|
||||
Map<String, TenantState> snapshot = tenantStates;
|
||||
String lookupKey = determineLookupKey(snapshot);
|
||||
TenantState state = snapshot.get(lookupKey);
|
||||
if (state == null || state.dataSource() == null) {
|
||||
throw new IllegalStateException("Подключение для выбранного тенанта не настроено");
|
||||
}
|
||||
return state.dataSource();
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет тенант и создаёт для него HikariCP пул.
|
||||
* Создаёт Hikari pool, но не включает его в маршрутизацию.
|
||||
*/
|
||||
public void addTenant(TenantConfig config) {
|
||||
String domain = config.getDomain().toLowerCase();
|
||||
HikariDataSource ds = createDataSource(config);
|
||||
|
||||
dataSources.put(domain, ds);
|
||||
tenantConfigs.put(domain, config);
|
||||
|
||||
// Обновляем target data sources
|
||||
setTargetDataSources(dataSources);
|
||||
afterPropertiesSet();
|
||||
initialized = true;
|
||||
|
||||
log.info("Added tenant '{}' -> {}", domain, config.getUrl());
|
||||
public HikariDataSource prepareTenantDataSource(TenantConfig config) {
|
||||
TenantConfig normalized = normalizeConfig(config);
|
||||
HikariDataSource dataSource = new HikariDataSource();
|
||||
dataSource.setJdbcUrl(normalized.getUrl());
|
||||
dataSource.setUsername(normalized.getUsername());
|
||||
dataSource.setPassword(normalized.getPassword());
|
||||
dataSource.setPoolName("tenant-" + normalized.getDomain() + "-candidate-"
|
||||
+ Integer.toUnsignedString(System.identityHashCode(dataSource)));
|
||||
dataSource.setMaximumPoolSize(10);
|
||||
dataSource.setMinimumIdle(2);
|
||||
dataSource.setConnectionTimeout(10_000);
|
||||
dataSource.setValidationTimeout(5_000);
|
||||
dataSource.setIdleTimeout(300_000);
|
||||
dataSource.setMaxLifetime(600_000);
|
||||
dataSource.setInitializationFailTimeout(10_000);
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет тенант и закрывает его пул соединений.
|
||||
* Атомарно публикует подготовленный DataSource и возвращает прежнее состояние.
|
||||
*/
|
||||
public void removeTenant(String domain) {
|
||||
domain = domain.toLowerCase();
|
||||
Object removed = dataSources.remove(domain);
|
||||
tenantConfigs.remove(domain);
|
||||
public TenantState swapTenant(TenantConfig config, DataSource candidate) {
|
||||
Objects.requireNonNull(candidate, "Подготовленный DataSource обязателен");
|
||||
TenantConfig normalized = normalizeConfig(config);
|
||||
String domain = normalized.getDomain();
|
||||
|
||||
if (removed instanceof HikariDataSource ds) {
|
||||
ds.close();
|
||||
log.info("Removed and closed tenant '{}'", domain);
|
||||
synchronized (lifecycleMonitor) {
|
||||
Map<String, TenantState> current = tenantStates;
|
||||
TenantState previous = copyState(current.get(domain));
|
||||
Map<String, TenantState> updated = new LinkedHashMap<>(current);
|
||||
updated.put(domain, new TenantState(domain, normalized, candidate));
|
||||
tenantStates = immutableStates(updated);
|
||||
return previous;
|
||||
}
|
||||
|
||||
setTargetDataSources(dataSources);
|
||||
afterPropertiesSet();
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет подключение к БД для указанного тенанта.
|
||||
* Атомарно исключает тенанта из маршрутизации, но не закрывает возвращённый DataSource.
|
||||
*/
|
||||
public TenantState removeTenantAtomically(String domain) {
|
||||
String normalizedDomain = normalizeDomain(domain);
|
||||
synchronized (lifecycleMonitor) {
|
||||
Map<String, TenantState> current = tenantStates;
|
||||
TenantState previous = current.get(normalizedDomain);
|
||||
if (previous == null) {
|
||||
return null;
|
||||
}
|
||||
Map<String, TenantState> updated = new LinkedHashMap<>(current);
|
||||
updated.remove(normalizedDomain);
|
||||
tenantStates = immutableStates(updated);
|
||||
return copyState(previous);
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<TenantState> snapshotTenant(String domain) {
|
||||
if (domain == null || domain.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(copyState(tenantStates.get(normalizeDomain(domain))));
|
||||
}
|
||||
|
||||
public Map<String, TenantConfig> snapshotTenantConfigs() {
|
||||
Map<String, TenantState> snapshot = tenantStates;
|
||||
Map<String, TenantConfig> configs = new LinkedHashMap<>();
|
||||
snapshot.forEach((domain, state) -> configs.put(domain, copyConfig(state.config())));
|
||||
return Collections.unmodifiableMap(configs);
|
||||
}
|
||||
|
||||
public Optional<DataSource> getTenantDataSource(String domain) {
|
||||
if (domain == null || domain.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
TenantState state = tenantStates.get(normalizeDomain(domain));
|
||||
return state == null ? Optional.empty() : Optional.of(state.dataSource());
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет подключение к БД для зарегистрированного тенанта.
|
||||
*/
|
||||
public boolean testConnection(String domain) {
|
||||
DataSource ds = (DataSource) dataSources.get(domain.toLowerCase());
|
||||
if (ds == null) return false;
|
||||
Optional<DataSource> tenantDataSource = getTenantDataSource(domain);
|
||||
if (tenantDataSource.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try (Connection conn = ds.getConnection()) {
|
||||
return conn.isValid(5);
|
||||
try (Connection connection = tenantDataSource.get().getConnection()) {
|
||||
return connection.isValid(5);
|
||||
} catch (SQLException e) {
|
||||
log.warn("Connection test failed for tenant '{}': {}", domain, e.getMessage());
|
||||
log.warn("Проверка подключения тенанта '{}' завершилась ошибкой: типОшибки={}",
|
||||
normalizeDomain(domain), e.getClass().getSimpleName());
|
||||
log.debug("Технические детали проверки подключения тенанта", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Тестирует подключение по произвольным параметрам (без регистрации тенанта).
|
||||
* Тестирует подключение по произвольным параметрам без регистрации тенанта.
|
||||
*/
|
||||
public String testExternalConnection(String url, String username, String password) {
|
||||
HikariDataSource ds = new HikariDataSource();
|
||||
ds.setJdbcUrl(url);
|
||||
ds.setUsername(username);
|
||||
ds.setPassword(password);
|
||||
ds.setMaximumPoolSize(1);
|
||||
ds.setConnectionTimeout(5000);
|
||||
HikariDataSource dataSource = new HikariDataSource();
|
||||
dataSource.setJdbcUrl(url);
|
||||
dataSource.setUsername(username);
|
||||
dataSource.setPassword(password);
|
||||
dataSource.setMaximumPoolSize(1);
|
||||
dataSource.setConnectionTimeout(5_000);
|
||||
|
||||
try (Connection conn = ds.getConnection()) {
|
||||
if (conn.isValid(5)) {
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
if (connection.isValid(5)) {
|
||||
return "OK";
|
||||
}
|
||||
return "Подключение не валидно";
|
||||
} catch (Exception e) {
|
||||
return e.getMessage();
|
||||
log.warn("Проверка внешнего подключения завершилась ошибкой: типОшибки={}",
|
||||
e.getClass().getSimpleName());
|
||||
log.debug("Технические детали проверки внешнего подключения", e);
|
||||
return "Не удалось подключиться к базе данных";
|
||||
} finally {
|
||||
ds.close();
|
||||
dataSource.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает неизменяемый снимок конфигураций для совместимости существующих consumers.
|
||||
*/
|
||||
public Map<String, TenantConfig> getTenantConfigs() {
|
||||
return tenantConfigs;
|
||||
return snapshotTenantConfigs();
|
||||
}
|
||||
|
||||
public boolean hasTenant(String domain) {
|
||||
return tenantConfigs.containsKey(domain.toLowerCase());
|
||||
return domain != null && !domain.isBlank() && tenantStates.containsKey(normalizeDomain(domain));
|
||||
}
|
||||
|
||||
public boolean isInitialized() {
|
||||
return initialized && !dataSources.isEmpty();
|
||||
return !tenantStates.isEmpty();
|
||||
}
|
||||
|
||||
private HikariDataSource createDataSource(TenantConfig config) {
|
||||
HikariDataSource ds = new HikariDataSource();
|
||||
ds.setJdbcUrl(config.getUrl());
|
||||
ds.setUsername(config.getUsername());
|
||||
ds.setPassword(config.getPassword());
|
||||
ds.setPoolName("tenant-" + config.getDomain());
|
||||
ds.setMaximumPoolSize(10);
|
||||
ds.setMinimumIdle(2);
|
||||
ds.setConnectionTimeout(10000);
|
||||
ds.setIdleTimeout(300000);
|
||||
ds.setMaxLifetime(600000);
|
||||
// Не падать при инициализации если БД недоступна
|
||||
ds.setInitializationFailTimeout(-1);
|
||||
return ds;
|
||||
private String determineLookupKey(Map<String, TenantState> snapshot) {
|
||||
String tenant = TenantContext.getCurrentTenant();
|
||||
if (tenant != null) {
|
||||
return tenant;
|
||||
}
|
||||
return snapshot.isEmpty() ? "default" : snapshot.keySet().iterator().next();
|
||||
}
|
||||
|
||||
private Map<String, TenantState> immutableStates(Map<String, TenantState> source) {
|
||||
Map<String, TenantState> copy = new LinkedHashMap<>();
|
||||
source.forEach((domain, state) -> copy.put(domain, new TenantState(
|
||||
domain,
|
||||
copyConfig(state.config()),
|
||||
state.dataSource()
|
||||
)));
|
||||
return Collections.unmodifiableMap(copy);
|
||||
}
|
||||
|
||||
private TenantState copyState(TenantState state) {
|
||||
if (state == null) {
|
||||
return null;
|
||||
}
|
||||
return new TenantState(state.domain(), copyConfig(state.config()), state.dataSource());
|
||||
}
|
||||
|
||||
private TenantConfig normalizeConfig(TenantConfig config) {
|
||||
if (config == null) {
|
||||
throw new IllegalArgumentException("Конфигурация тенанта обязательна");
|
||||
}
|
||||
String domain = normalizeDomain(config.getDomain());
|
||||
if (config.getUrl() == null || config.getUrl().isBlank()) {
|
||||
throw new IllegalArgumentException("URL базы данных не может быть пустым");
|
||||
}
|
||||
String name = config.getName() == null || config.getName().isBlank()
|
||||
? domain
|
||||
: config.getName().trim();
|
||||
return new TenantConfig(
|
||||
name,
|
||||
domain,
|
||||
config.getUrl().trim(),
|
||||
config.getUsername(),
|
||||
config.getPassword()
|
||||
);
|
||||
}
|
||||
|
||||
private TenantConfig copyConfig(TenantConfig config) {
|
||||
if (config == null) {
|
||||
return null;
|
||||
}
|
||||
return new TenantConfig(
|
||||
config.getName(),
|
||||
config.getDomain(),
|
||||
config.getUrl(),
|
||||
config.getUsername(),
|
||||
config.getPassword()
|
||||
);
|
||||
}
|
||||
|
||||
private String normalizeDomain(String domain) {
|
||||
if (domain == null || domain.isBlank()) {
|
||||
throw new IllegalArgumentException("Домен не может быть пустым");
|
||||
}
|
||||
return domain.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
public record TenantState(String domain, TenantConfig config, DataSource dataSource) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
/**
|
||||
* Результат компенсирующего изменения конфигурации тенантов.
|
||||
*/
|
||||
public enum TenantSecretCompensationResult {
|
||||
RESTORED,
|
||||
NOT_REQUIRED,
|
||||
SKIPPED_CONCURRENT_CHANGE
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Квитанция об атомарном изменении tenant Secret.
|
||||
*
|
||||
* <p>Снимки конфигурации копируются глубоко и сортируются по домену. Это позволяет
|
||||
* безопасно использовать квитанцию для компенсации и проверки ожидаемого состояния.</p>
|
||||
*/
|
||||
public final class TenantSecretUpdateReceipt {
|
||||
|
||||
private static final Comparator<TenantConfig> BY_DOMAIN = Comparator.comparing(
|
||||
TenantConfig::getDomain,
|
||||
Comparator.nullsFirst(String::compareTo)
|
||||
);
|
||||
|
||||
private final boolean persisted;
|
||||
private final boolean changed;
|
||||
private final String committedResourceVersion;
|
||||
private final List<TenantConfig> previousTenants;
|
||||
private final List<TenantConfig> committedTenants;
|
||||
|
||||
public TenantSecretUpdateReceipt(boolean persisted,
|
||||
boolean changed,
|
||||
String committedResourceVersion,
|
||||
List<TenantConfig> previousTenants,
|
||||
List<TenantConfig> committedTenants) {
|
||||
this.persisted = persisted;
|
||||
this.changed = changed;
|
||||
this.committedResourceVersion = committedResourceVersion;
|
||||
this.previousTenants = copyAndSort(previousTenants);
|
||||
this.committedTenants = copyAndSort(committedTenants);
|
||||
}
|
||||
|
||||
public boolean persisted() {
|
||||
return persisted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает {@code true}, только если хранилище доказало владение подтверждённой
|
||||
* записанной версией. После неоднозначного результата повторное чтение может подтвердить
|
||||
* целевое состояние, но оставляет этот признак {@code false}, чтобы запретить опасную
|
||||
* автоматическую компенсацию.
|
||||
*/
|
||||
public boolean changed() {
|
||||
return changed;
|
||||
}
|
||||
|
||||
public String committedResourceVersion() {
|
||||
return committedResourceVersion;
|
||||
}
|
||||
|
||||
public List<TenantConfig> previousTenants() {
|
||||
return copyAndSort(previousTenants);
|
||||
}
|
||||
|
||||
public List<TenantConfig> committedTenants() {
|
||||
return copyAndSort(committedTenants);
|
||||
}
|
||||
|
||||
static List<TenantConfig> copyAndSort(List<TenantConfig> tenants) {
|
||||
Objects.requireNonNull(tenants, "Список конфигураций тенантов не задан");
|
||||
List<TenantConfig> result = new ArrayList<>(tenants.size());
|
||||
for (TenantConfig tenant : tenants) {
|
||||
result.add(copyOf(Objects.requireNonNull(tenant, "Конфигурация тенанта не задана")));
|
||||
}
|
||||
result.sort(BY_DOMAIN);
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
static TenantConfig copyOf(TenantConfig source) {
|
||||
return new TenantConfig(
|
||||
source.getName(),
|
||||
source.getDomain(),
|
||||
source.getUrl(),
|
||||
source.getUsername(),
|
||||
source.getPassword()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "TenantSecretUpdateReceipt{" +
|
||||
"persisted=" + persisted +
|
||||
", changed=" + changed +
|
||||
", committedResourceVersion='" + committedResourceVersion + '\'' +
|
||||
", previousTenantCount=" + previousTenants.size() +
|
||||
", committedTenantCount=" + committedTenants.size() +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,9 @@ public class TenantWebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(tenantInterceptor()).addPathPatterns("/**");
|
||||
registry.addInterceptor(tenantInterceptor())
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns("/actuator/**");
|
||||
registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.magistr.app.config.tenant.health;
|
||||
|
||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* Фоновая проверка доступности tenant-БД с ограниченным числом параллельных задач.
|
||||
*/
|
||||
@Component
|
||||
public class TenantDatabaseHealthMonitor {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantDatabaseHealthMonitor.class);
|
||||
|
||||
private final TenantReadinessRegistry registry;
|
||||
private final TenantRoutingDataSource routingDataSource;
|
||||
private final int parallelism;
|
||||
private final Duration checkTimeout;
|
||||
private final Duration maxStaleness;
|
||||
private final Clock clock;
|
||||
private final ExecutorService executor;
|
||||
private final AtomicBoolean refreshInProgress = new AtomicBoolean(false);
|
||||
|
||||
@Autowired
|
||||
public TenantDatabaseHealthMonitor(
|
||||
TenantReadinessRegistry registry,
|
||||
TenantRoutingDataSource routingDataSource,
|
||||
@Value("${app.health.tenant-database.parallelism:4}") int parallelism,
|
||||
@Value("${app.health.tenant-database.check-timeout-ms:6000}") long checkTimeoutMillis,
|
||||
@Value("${app.health.tenant-database.max-staleness-ms:30000}") long maxStalenessMillis) {
|
||||
this(registry, routingDataSource, parallelism,
|
||||
Duration.ofMillis(checkTimeoutMillis),
|
||||
Duration.ofMillis(maxStalenessMillis),
|
||||
Clock.systemUTC());
|
||||
}
|
||||
|
||||
public TenantDatabaseHealthMonitor(TenantReadinessRegistry registry,
|
||||
TenantRoutingDataSource routingDataSource,
|
||||
int parallelism,
|
||||
Duration checkTimeout,
|
||||
Duration maxStaleness,
|
||||
Clock clock) {
|
||||
this.registry = Objects.requireNonNull(registry, "Реестр готовности обязателен");
|
||||
this.routingDataSource = Objects.requireNonNull(routingDataSource, "Маршрутизатор tenant-БД обязателен");
|
||||
if (parallelism < 1 || parallelism > 32) {
|
||||
throw new IllegalArgumentException("Параллелизм проверки должен быть от 1 до 32");
|
||||
}
|
||||
this.parallelism = parallelism;
|
||||
this.checkTimeout = requirePositive(checkTimeout, "Таймаут проверки должен быть положительным");
|
||||
this.maxStaleness = requirePositive(maxStaleness, "Максимальный возраст проверки должен быть положительным");
|
||||
this.clock = Objects.requireNonNull(clock, "Часы монитора обязательны");
|
||||
this.executor = Executors.newFixedThreadPool(parallelism, new HealthThreadFactory());
|
||||
}
|
||||
|
||||
@Scheduled(
|
||||
fixedDelayString = "${app.health.tenant-database.interval-ms:10000}",
|
||||
initialDelayString = "${app.health.tenant-database.initial-delay-ms:1000}")
|
||||
public void refreshScheduled() {
|
||||
refreshNow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет немедленный проход. Одновременно может выполняться только один проход.
|
||||
*/
|
||||
public RefreshSummary refreshNow() {
|
||||
if (!refreshInProgress.compareAndSet(false, true)) {
|
||||
return new RefreshSummary(0, 0, 0, true);
|
||||
}
|
||||
|
||||
try {
|
||||
List<TenantReadinessRegistry.ConnectivityTarget> targets = registry.connectivityTargets();
|
||||
int available = 0;
|
||||
int unavailable = 0;
|
||||
|
||||
for (int offset = 0; offset < targets.size(); offset += parallelism) {
|
||||
int end = Math.min(offset + parallelism, targets.size());
|
||||
List<TenantReadinessRegistry.ConnectivityTarget> batch = targets.subList(offset, end);
|
||||
List<Callable<CheckResult>> checks = new ArrayList<>(batch.size());
|
||||
|
||||
for (TenantReadinessRegistry.ConnectivityTarget target : batch) {
|
||||
checks.add(() -> check(target));
|
||||
}
|
||||
|
||||
List<Future<CheckResult>> futures;
|
||||
try {
|
||||
futures = executor.invokeAll(checks, checkTimeout.toNanos(), TimeUnit.NANOSECONDS);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
Instant interruptedAt = clock.instant();
|
||||
log.warn("Пакетная проверка tenant-БД была прервана");
|
||||
for (int remaining = offset; remaining < targets.size(); remaining++) {
|
||||
TenantReadinessRegistry.ConnectivityTarget target = targets.get(remaining);
|
||||
if (registry.markConnectivity(
|
||||
target.domain(), target.fingerprint(), false, interruptedAt)) {
|
||||
unavailable++;
|
||||
}
|
||||
}
|
||||
return new RefreshSummary(targets.size(), available, unavailable, false);
|
||||
}
|
||||
|
||||
for (int index = 0; index < batch.size(); index++) {
|
||||
TenantReadinessRegistry.ConnectivityTarget target = batch.get(index);
|
||||
CheckResult result = completedResult(target, futures.get(index));
|
||||
boolean applied = registry.markConnectivity(
|
||||
target.domain(), target.fingerprint(), result.connected(), result.checkedAt());
|
||||
if (applied && result.connected()) {
|
||||
available++;
|
||||
} else if (applied) {
|
||||
unavailable++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new RefreshSummary(targets.size(), available, unavailable, false);
|
||||
} finally {
|
||||
refreshInProgress.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
public Duration maxStaleness() {
|
||||
return maxStaleness;
|
||||
}
|
||||
|
||||
public int parallelism() {
|
||||
return parallelism;
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void close() {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
private CheckResult check(TenantReadinessRegistry.ConnectivityTarget target) {
|
||||
try {
|
||||
boolean connected = routingDataSource.testConnection(target.domain());
|
||||
return new CheckResult(connected, clock.instant());
|
||||
} catch (RuntimeException checkFailure) {
|
||||
log.warn("Проверка tenant-БД '{}' завершилась ошибкой: errorType={}",
|
||||
target.domain(), checkFailure.getClass().getSimpleName());
|
||||
log.debug("Технические детали фоновой проверки tenant-БД", checkFailure);
|
||||
return new CheckResult(false, clock.instant());
|
||||
}
|
||||
}
|
||||
|
||||
private CheckResult completedResult(TenantReadinessRegistry.ConnectivityTarget target,
|
||||
Future<CheckResult> future) {
|
||||
if (future.isCancelled()) {
|
||||
log.warn("Проверка tenant-БД '{}' превысила допустимое время", target.domain());
|
||||
return new CheckResult(false, clock.instant());
|
||||
}
|
||||
|
||||
try {
|
||||
return future.get();
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
future.cancel(true);
|
||||
log.warn("Проверка tenant-БД '{}' была прервана", target.domain());
|
||||
return new CheckResult(false, clock.instant());
|
||||
} catch (ExecutionException failure) {
|
||||
log.warn("Не удалось проверить tenant-БД '{}': errorType={}",
|
||||
target.domain(), failure.getCause() == null
|
||||
? failure.getClass().getSimpleName()
|
||||
: failure.getCause().getClass().getSimpleName());
|
||||
return new CheckResult(false, clock.instant());
|
||||
}
|
||||
}
|
||||
|
||||
private Duration requirePositive(Duration value, String message) {
|
||||
Objects.requireNonNull(value, message);
|
||||
if (value.isNegative() || value.isZero()) {
|
||||
throw new IllegalArgumentException(message);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
public record RefreshSummary(int requested,
|
||||
int available,
|
||||
int unavailable,
|
||||
boolean skipped) {
|
||||
}
|
||||
|
||||
private record CheckResult(boolean connected, Instant checkedAt) {
|
||||
private CheckResult {
|
||||
Objects.requireNonNull(checkedAt, "Время завершения проверки tenant-БД обязательно");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class HealthThreadFactory implements ThreadFactory {
|
||||
private final AtomicInteger sequence = new AtomicInteger();
|
||||
|
||||
@Override
|
||||
public Thread newThread(Runnable task) {
|
||||
Thread thread = new Thread(task,
|
||||
"проверка-готовности-tenant-бд-" + sequence.incrementAndGet());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.magistr.app.config.tenant.health;
|
||||
|
||||
import org.springframework.boot.actuate.health.Health;
|
||||
import org.springframework.boot.actuate.health.HealthIndicator;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Агрегированный индикатор readiness без раскрытия доменов и реквизитов tenant-БД.
|
||||
*/
|
||||
@Component("tenantReadiness")
|
||||
public class TenantReadinessHealthIndicator implements HealthIndicator {
|
||||
|
||||
private final TenantReadinessRegistry registry;
|
||||
private final TenantDatabaseHealthMonitor monitor;
|
||||
|
||||
public TenantReadinessHealthIndicator(TenantReadinessRegistry registry,
|
||||
TenantDatabaseHealthMonitor monitor) {
|
||||
this.registry = Objects.requireNonNull(registry, "Реестр готовности обязателен");
|
||||
this.monitor = Objects.requireNonNull(monitor, "Монитор tenant-БД обязателен");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Health health() {
|
||||
return readiness().ready()
|
||||
? Health.up().build()
|
||||
: Health.down().build();
|
||||
}
|
||||
|
||||
public TenantReadinessRegistry.AggregateReadiness readiness() {
|
||||
return registry.aggregate(monitor.maxStaleness());
|
||||
}
|
||||
|
||||
public boolean isReady() {
|
||||
return readiness().ready();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
package com.magistr.app.config.tenant.health;
|
||||
|
||||
import com.magistr.app.config.tenant.TenantConfig;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.UnaryOperator;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Потокобезопасный реестр готовности обязательных tenant-БД.
|
||||
*
|
||||
* <p>Каждое изменение публикуется единым неизменяемым снимком. Результат фоновой
|
||||
* проверки соединения привязан к непрозрачному fingerprint конфигурации, поэтому
|
||||
* запоздалый результат для старых реквизитов не изменит состояние новой конфигурации.</p>
|
||||
*/
|
||||
@Component
|
||||
public class TenantReadinessRegistry {
|
||||
|
||||
private static final String FINGERPRINT_ALGORITHM = "HmacSHA256";
|
||||
private static final Pattern DOMAIN_PATTERN = Pattern.compile(
|
||||
"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
|
||||
);
|
||||
|
||||
private final AtomicReference<RegistryState> state = new AtomicReference<>(RegistryState.empty());
|
||||
private final Clock clock;
|
||||
private final byte[] fingerprintKey;
|
||||
|
||||
public TenantReadinessRegistry() {
|
||||
this(Clock.systemUTC(), randomFingerprintKey());
|
||||
}
|
||||
|
||||
TenantReadinessRegistry(Clock clock, byte[] fingerprintKey) {
|
||||
this.clock = Objects.requireNonNull(clock, "Часы реестра готовности обязательны");
|
||||
if (fingerprintKey == null || fingerprintKey.length < 16) {
|
||||
throw new IllegalArgumentException("Ключ fingerprint должен содержать не менее 16 байт");
|
||||
}
|
||||
this.fingerprintKey = fingerprintKey.clone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Полностью заменяет список обязательных конфигураций.
|
||||
* Состояние неизменившегося tenant сохраняется, изменившегося — сбрасывается.
|
||||
*/
|
||||
public void replaceDesired(List<TenantConfig> desired) {
|
||||
boolean missingDesired = desired == null;
|
||||
List<TenantConfig> safeDesired = desired == null ? List.of() : new ArrayList<>(desired);
|
||||
mutate(current -> {
|
||||
Map<String, TenantStatus> replacement = new LinkedHashMap<>();
|
||||
boolean invalidConfiguration = missingDesired;
|
||||
|
||||
for (TenantConfig config : safeDesired) {
|
||||
NormalizedConfig normalized = normalize(config);
|
||||
if (normalized == null || replacement.containsKey(normalized.domain())) {
|
||||
invalidConfiguration = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
String fingerprint = fingerprint(normalized);
|
||||
TenantStatus previous = current.tenants().get(normalized.domain());
|
||||
if (previous != null && previous.fingerprint().equals(fingerprint)) {
|
||||
replacement.put(normalized.domain(), previous);
|
||||
} else {
|
||||
replacement.put(normalized.domain(), TenantStatus.pending(
|
||||
normalized.domain(), fingerprint, normalized.h2Placeholder(), clock.instant()));
|
||||
}
|
||||
}
|
||||
|
||||
return new RegistryState(replacement,
|
||||
current.configurationFailure() || invalidConfiguration);
|
||||
});
|
||||
}
|
||||
|
||||
/** Удаляет tenant из обязательного набора. */
|
||||
public void removeDesired(String domain) {
|
||||
String normalizedDomain = normalizeDomain(domain);
|
||||
mutate(current -> {
|
||||
if (!current.tenants().containsKey(normalizedDomain)) {
|
||||
return current;
|
||||
}
|
||||
Map<String, TenantStatus> updated = new LinkedHashMap<>(current.tenants());
|
||||
updated.remove(normalizedDomain);
|
||||
return new RegistryState(updated, current.configurationFailure());
|
||||
});
|
||||
}
|
||||
|
||||
public void markMigrationStarted(TenantConfig config) {
|
||||
updateByConfig(config, MigrationState.IN_PROGRESS, ConnectivityState.UNKNOWN);
|
||||
}
|
||||
|
||||
public void markMigrationSucceeded(TenantConfig config) {
|
||||
updateByConfig(config, MigrationState.SUCCEEDED, ConnectivityState.UNKNOWN);
|
||||
}
|
||||
|
||||
public void markMigrationFailed(TenantConfig config) {
|
||||
updateByConfig(config, MigrationState.FAILED, ConnectivityState.UNKNOWN);
|
||||
}
|
||||
|
||||
public void markMigrationStarted(String domain) {
|
||||
updateExisting(domain, MigrationState.IN_PROGRESS, ConnectivityState.UNKNOWN);
|
||||
}
|
||||
|
||||
public void markMigrationSucceeded(String domain) {
|
||||
updateExisting(domain, MigrationState.SUCCEEDED, ConnectivityState.UNKNOWN);
|
||||
}
|
||||
|
||||
public void markMigrationFailed(String domain) {
|
||||
updateExisting(domain, MigrationState.FAILED, ConnectivityState.UNKNOWN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет результат проверки текущей конфигурации tenant.
|
||||
*/
|
||||
public boolean markConnectivity(String domain, boolean connected) {
|
||||
String normalizedDomain = normalizeDomain(domain);
|
||||
TenantStatus current = state.get().tenants().get(normalizedDomain);
|
||||
if (current == null) {
|
||||
return false;
|
||||
}
|
||||
return markConnectivity(normalizedDomain, current.fingerprint(), connected, clock.instant());
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет результат только если fingerprint всё ещё соответствует проверенной конфигурации.
|
||||
*/
|
||||
public boolean markConnectivity(String domain,
|
||||
String expectedFingerprint,
|
||||
boolean connected,
|
||||
Instant checkedAt) {
|
||||
String normalizedDomain = normalizeDomain(domain);
|
||||
Objects.requireNonNull(expectedFingerprint, "Fingerprint проверяемой конфигурации обязателен");
|
||||
Objects.requireNonNull(checkedAt, "Время проверки соединения обязательно");
|
||||
|
||||
AtomicReference<Boolean> applied = new AtomicReference<>(false);
|
||||
mutate(current -> {
|
||||
applied.set(false);
|
||||
TenantStatus previous = current.tenants().get(normalizedDomain);
|
||||
if (previous == null || !previous.fingerprint().equals(expectedFingerprint)
|
||||
|| previous.migrationState() != MigrationState.SUCCEEDED
|
||||
|| previous.h2Placeholder()) {
|
||||
return current;
|
||||
}
|
||||
|
||||
if (previous.connectivityCheckedAt() != null
|
||||
&& checkedAt.isBefore(previous.connectivityCheckedAt())) {
|
||||
return current;
|
||||
}
|
||||
|
||||
Map<String, TenantStatus> updated = new LinkedHashMap<>(current.tenants());
|
||||
updated.put(normalizedDomain, previous.withConnectivity(
|
||||
connected ? ConnectivityState.AVAILABLE : ConnectivityState.UNAVAILABLE,
|
||||
checkedAt,
|
||||
clock.instant()));
|
||||
applied.set(true);
|
||||
return new RegistryState(updated, current.configurationFailure());
|
||||
});
|
||||
return applied.get();
|
||||
}
|
||||
|
||||
/** Отмечает ошибку чтения или разбора общей конфигурации без сохранения её текста. */
|
||||
public void markConfigurationFailure() {
|
||||
mutate(current -> new RegistryState(current.tenants(), true));
|
||||
}
|
||||
|
||||
public void clearConfigurationFailure() {
|
||||
mutate(current -> new RegistryState(current.tenants(), false));
|
||||
}
|
||||
|
||||
/** Возвращает безопасные задания для фоновой проверки соединений. */
|
||||
public List<ConnectivityTarget> connectivityTargets() {
|
||||
List<ConnectivityTarget> targets = new ArrayList<>();
|
||||
state.get().tenants().values().stream()
|
||||
.filter(status -> status.migrationState() == MigrationState.SUCCEEDED)
|
||||
.filter(status -> !status.h2Placeholder())
|
||||
.forEach(status -> targets.add(new ConnectivityTarget(status.domain(), status.fingerprint())));
|
||||
return List.copyOf(targets);
|
||||
}
|
||||
|
||||
/** Формирует агрегированную готовность на текущий момент. */
|
||||
public AggregateReadiness aggregate(Duration maxStaleness) {
|
||||
Objects.requireNonNull(maxStaleness, "Максимальный возраст проверки обязателен");
|
||||
if (maxStaleness.isNegative() || maxStaleness.isZero()) {
|
||||
throw new IllegalArgumentException("Максимальный возраст проверки должен быть положительным");
|
||||
}
|
||||
|
||||
RegistryState snapshot = state.get();
|
||||
Instant assessedAt = clock.instant();
|
||||
Instant oldestAllowed = assessedAt.minus(maxStaleness);
|
||||
int ready = 0;
|
||||
int migrationFailures = 0;
|
||||
int migrationPending = 0;
|
||||
int staleConnectivity = 0;
|
||||
int unavailableConnectivity = 0;
|
||||
int unsupported = 0;
|
||||
|
||||
for (TenantStatus status : snapshot.tenants().values()) {
|
||||
if (status.h2Placeholder()) {
|
||||
unsupported++;
|
||||
continue;
|
||||
}
|
||||
if (status.migrationState() == MigrationState.FAILED) {
|
||||
migrationFailures++;
|
||||
continue;
|
||||
}
|
||||
if (status.migrationState() != MigrationState.SUCCEEDED) {
|
||||
migrationPending++;
|
||||
continue;
|
||||
}
|
||||
if (status.connectivityCheckedAt() == null
|
||||
|| status.connectivityCheckedAt().isBefore(oldestAllowed)) {
|
||||
staleConnectivity++;
|
||||
continue;
|
||||
}
|
||||
if (status.connectivityState() != ConnectivityState.AVAILABLE) {
|
||||
unavailableConnectivity++;
|
||||
continue;
|
||||
}
|
||||
ready++;
|
||||
}
|
||||
|
||||
int desiredCount = snapshot.tenants().size();
|
||||
boolean isReady = !snapshot.configurationFailure()
|
||||
&& desiredCount > 0
|
||||
&& unsupported == 0
|
||||
&& ready == desiredCount;
|
||||
return new AggregateReadiness(
|
||||
isReady,
|
||||
isReady ? "ГОТОВ" : "НЕ ГОТОВ",
|
||||
desiredCount,
|
||||
ready,
|
||||
desiredCount - ready,
|
||||
migrationPending,
|
||||
migrationFailures,
|
||||
unavailableConnectivity,
|
||||
staleConnectivity,
|
||||
unsupported,
|
||||
snapshot.configurationFailure(),
|
||||
assessedAt
|
||||
);
|
||||
}
|
||||
|
||||
/** Неизменяемый диагностический снимок для внутренних тестов и интеграции. */
|
||||
public Map<String, TenantStatus> snapshot() {
|
||||
return state.get().tenants();
|
||||
}
|
||||
|
||||
private void updateByConfig(TenantConfig config,
|
||||
MigrationState migrationState,
|
||||
ConnectivityState connectivityState) {
|
||||
NormalizedConfig normalized = normalize(config);
|
||||
if (normalized == null) {
|
||||
markConfigurationFailure();
|
||||
return;
|
||||
}
|
||||
String fingerprint = fingerprint(normalized);
|
||||
mutate(current -> {
|
||||
TenantStatus previous = current.tenants().get(normalized.domain());
|
||||
TenantStatus base = previous != null && previous.fingerprint().equals(fingerprint)
|
||||
? previous
|
||||
: TenantStatus.pending(normalized.domain(), fingerprint, normalized.h2Placeholder(), clock.instant());
|
||||
Map<String, TenantStatus> updated = new LinkedHashMap<>(current.tenants());
|
||||
updated.put(normalized.domain(), base.withMigration(migrationState, connectivityState, clock.instant()));
|
||||
return new RegistryState(updated, current.configurationFailure());
|
||||
});
|
||||
}
|
||||
|
||||
private void updateExisting(String domain,
|
||||
MigrationState migrationState,
|
||||
ConnectivityState connectivityState) {
|
||||
String normalizedDomain = normalizeDomain(domain);
|
||||
mutate(current -> {
|
||||
TenantStatus previous = current.tenants().get(normalizedDomain);
|
||||
if (previous == null) {
|
||||
return current;
|
||||
}
|
||||
Map<String, TenantStatus> updated = new LinkedHashMap<>(current.tenants());
|
||||
updated.put(normalizedDomain, previous.withMigration(migrationState, connectivityState, clock.instant()));
|
||||
return new RegistryState(updated, current.configurationFailure());
|
||||
});
|
||||
}
|
||||
|
||||
private void mutate(UnaryOperator<RegistryState> mutation) {
|
||||
RegistryState current;
|
||||
RegistryState updated;
|
||||
do {
|
||||
current = state.get();
|
||||
updated = Objects.requireNonNull(mutation.apply(current), "Новое состояние реестра обязательно");
|
||||
if (updated == current) {
|
||||
return;
|
||||
}
|
||||
} while (!state.compareAndSet(current, updated));
|
||||
}
|
||||
|
||||
private NormalizedConfig normalize(TenantConfig config) {
|
||||
if (config == null || config.getDomain() == null || config.getDomain().isBlank()
|
||||
|| config.getUrl() == null || config.getUrl().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String domain = config.getDomain().trim().toLowerCase(Locale.ROOT);
|
||||
String url = config.getUrl().trim();
|
||||
if (!DOMAIN_PATTERN.matcher(domain).matches()) {
|
||||
return null;
|
||||
}
|
||||
String name = config.getName() == null || config.getName().isBlank()
|
||||
? domain
|
||||
: config.getName().trim();
|
||||
return new NormalizedConfig(
|
||||
name,
|
||||
domain,
|
||||
url,
|
||||
config.getUsername(),
|
||||
config.getPassword(),
|
||||
url.startsWith("jdbc:h2:")
|
||||
);
|
||||
}
|
||||
|
||||
private String normalizeDomain(String domain) {
|
||||
if (domain == null || domain.isBlank()) {
|
||||
throw new IllegalArgumentException("Домен tenant не может быть пустым");
|
||||
}
|
||||
return domain.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String fingerprint(NormalizedConfig normalized) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance(FINGERPRINT_ALGORITHM);
|
||||
mac.init(new SecretKeySpec(fingerprintKey, FINGERPRINT_ALGORITHM));
|
||||
updateFingerprint(mac, normalized.name());
|
||||
updateFingerprint(mac, normalized.domain());
|
||||
updateFingerprint(mac, normalized.url());
|
||||
updateFingerprint(mac, normalized.username());
|
||||
updateFingerprint(mac, normalized.password());
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal());
|
||||
} catch (GeneralSecurityException cryptoFailure) {
|
||||
throw new IllegalStateException("Не удалось сформировать безопасный fingerprint конфигурации", cryptoFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateFingerprint(Mac mac, String value) {
|
||||
byte[] bytes = value == null ? new byte[0] : value.getBytes(StandardCharsets.UTF_8);
|
||||
mac.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
|
||||
mac.update(bytes);
|
||||
}
|
||||
|
||||
private static byte[] randomFingerprintKey() {
|
||||
byte[] key = new byte[32];
|
||||
new SecureRandom().nextBytes(key);
|
||||
return key;
|
||||
}
|
||||
|
||||
public enum MigrationState {
|
||||
PENDING,
|
||||
IN_PROGRESS,
|
||||
SUCCEEDED,
|
||||
FAILED
|
||||
}
|
||||
|
||||
public enum ConnectivityState {
|
||||
UNKNOWN,
|
||||
AVAILABLE,
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
public record ConnectivityTarget(String domain, String fingerprint) {
|
||||
public ConnectivityTarget {
|
||||
Objects.requireNonNull(domain, "Домен проверки обязателен");
|
||||
Objects.requireNonNull(fingerprint, "Fingerprint проверки обязателен");
|
||||
}
|
||||
}
|
||||
|
||||
public record TenantStatus(String domain,
|
||||
String fingerprint,
|
||||
MigrationState migrationState,
|
||||
ConnectivityState connectivityState,
|
||||
Instant connectivityCheckedAt,
|
||||
boolean h2Placeholder,
|
||||
Instant changedAt) {
|
||||
public TenantStatus {
|
||||
Objects.requireNonNull(domain, "Домен состояния обязателен");
|
||||
Objects.requireNonNull(fingerprint, "Fingerprint состояния обязателен");
|
||||
Objects.requireNonNull(migrationState, "Состояние миграции обязательно");
|
||||
Objects.requireNonNull(connectivityState, "Состояние соединения обязательно");
|
||||
Objects.requireNonNull(changedAt, "Время изменения состояния обязательно");
|
||||
}
|
||||
|
||||
private static TenantStatus pending(String domain,
|
||||
String fingerprint,
|
||||
boolean h2Placeholder,
|
||||
Instant changedAt) {
|
||||
return new TenantStatus(domain, fingerprint, MigrationState.PENDING,
|
||||
ConnectivityState.UNKNOWN, null, h2Placeholder, changedAt);
|
||||
}
|
||||
|
||||
private TenantStatus withMigration(MigrationState newMigrationState,
|
||||
ConnectivityState newConnectivityState,
|
||||
Instant now) {
|
||||
return new TenantStatus(domain, fingerprint, newMigrationState,
|
||||
newConnectivityState, null, h2Placeholder, now);
|
||||
}
|
||||
|
||||
private TenantStatus withConnectivity(ConnectivityState newConnectivityState,
|
||||
Instant checkedAt,
|
||||
Instant now) {
|
||||
return new TenantStatus(domain, fingerprint, migrationState,
|
||||
newConnectivityState, checkedAt, h2Placeholder, now);
|
||||
}
|
||||
}
|
||||
|
||||
public record AggregateReadiness(boolean ready,
|
||||
String status,
|
||||
int desiredTenants,
|
||||
int readyTenants,
|
||||
int unavailableTenants,
|
||||
int pendingMigrations,
|
||||
int failedMigrations,
|
||||
int unavailableConnections,
|
||||
int staleConnections,
|
||||
int unsupportedTenants,
|
||||
boolean configurationFailure,
|
||||
Instant assessedAt) {
|
||||
public AggregateReadiness {
|
||||
Objects.requireNonNull(status, "Текст готовности обязателен");
|
||||
Objects.requireNonNull(assessedAt, "Время оценки готовности обязательно");
|
||||
}
|
||||
}
|
||||
|
||||
private record NormalizedConfig(String name,
|
||||
String domain,
|
||||
String url,
|
||||
String username,
|
||||
String password,
|
||||
boolean h2Placeholder) {
|
||||
}
|
||||
|
||||
private record RegistryState(Map<String, TenantStatus> tenants, boolean configurationFailure) {
|
||||
private RegistryState {
|
||||
tenants = Collections.unmodifiableMap(new LinkedHashMap<>(tenants));
|
||||
}
|
||||
|
||||
private static RegistryState empty() {
|
||||
return new RegistryState(Map.of(), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.*;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.AcademicPeriodService;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -22,17 +23,20 @@ public class AcademicCalendarAdminController {
|
||||
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
|
||||
private final AcademicCalendarDayRepository calendarDayRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
private final AcademicPeriodService academicPeriodService;
|
||||
|
||||
public AcademicCalendarAdminController(AcademicYearRepository academicYearRepository,
|
||||
SemesterRepository semesterRepository,
|
||||
AcademicCalendarActivityTypeRepository activityTypeRepository,
|
||||
AcademicCalendarDayRepository calendarDayRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
ScheduleGeneratorService scheduleGeneratorService,
|
||||
AcademicPeriodService academicPeriodService) {
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.semesterRepository = semesterRepository;
|
||||
this.activityTypeRepository = activityTypeRepository;
|
||||
this.calendarDayRepository = calendarDayRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
this.academicPeriodService = academicPeriodService;
|
||||
}
|
||||
|
||||
@GetMapping("/years")
|
||||
@@ -45,43 +49,17 @@ public class AcademicCalendarAdminController {
|
||||
|
||||
@PostMapping("/years")
|
||||
public ResponseEntity<?> createYear(@RequestBody AcademicYearDto request) {
|
||||
String validationError = validateYear(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (academicYearRepository.findByTitle(request.title()).isPresent()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год с таким названием уже существует"));
|
||||
}
|
||||
|
||||
AcademicYear year = new AcademicYear();
|
||||
applyYear(year, request);
|
||||
return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year)));
|
||||
return ResponseEntity.ok(toAcademicYearDto(academicPeriodService.createYear(request)));
|
||||
}
|
||||
|
||||
@PutMapping("/years/{id}")
|
||||
public ResponseEntity<?> updateYear(@PathVariable Long id, @RequestBody AcademicYearDto request) {
|
||||
AcademicYear year = academicYearRepository.findById(id).orElse(null);
|
||||
if (year == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
String validationError = validateYear(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
|
||||
applyYear(year, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year)));
|
||||
return ResponseEntity.ok(toAcademicYearDto(academicPeriodService.updateYear(id, request)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/years/{id}")
|
||||
public ResponseEntity<?> deleteYear(@PathVariable Long id) {
|
||||
if (!academicYearRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
academicYearRepository.deleteById(id);
|
||||
scheduleGeneratorService.clearCache();
|
||||
academicPeriodService.deleteYear(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Учебный год удалён"));
|
||||
}
|
||||
|
||||
@@ -94,39 +72,12 @@ public class AcademicCalendarAdminController {
|
||||
|
||||
@PostMapping("/years/{academicYearId}/semesters")
|
||||
public ResponseEntity<?> createSemester(@PathVariable Long academicYearId, @RequestBody SemesterDto request) {
|
||||
AcademicYear year = academicYearRepository.findById(academicYearId).orElse(null);
|
||||
if (year == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден"));
|
||||
}
|
||||
String validationError = validateSemester(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (semesterRepository.findByAcademicYearIdAndSemesterType(academicYearId, request.semesterType()).isPresent()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Такой семестр уже есть в учебном году"));
|
||||
}
|
||||
|
||||
Semester semester = new Semester();
|
||||
semester.setAcademicYear(year);
|
||||
applySemester(semester, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester)));
|
||||
return ResponseEntity.ok(toSemesterDto(academicPeriodService.createSemester(academicYearId, request)));
|
||||
}
|
||||
|
||||
@PutMapping("/semesters/{id}")
|
||||
public ResponseEntity<?> updateSemester(@PathVariable Long id, @RequestBody SemesterDto request) {
|
||||
Semester semester = semesterRepository.findById(id).orElse(null);
|
||||
if (semester == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
String validationError = validateSemester(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
|
||||
applySemester(semester, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester)));
|
||||
return ResponseEntity.ok(toSemesterDto(academicPeriodService.updateSemester(id, request)));
|
||||
}
|
||||
|
||||
@GetMapping("/activity-types")
|
||||
@@ -185,32 +136,6 @@ public class AcademicCalendarAdminController {
|
||||
return ResponseEntity.ok(Map.of("message", "Код активности удалён"));
|
||||
}
|
||||
|
||||
private String validateYear(AcademicYearDto request) {
|
||||
if (request.title() == null || request.title().isBlank()) {
|
||||
return "Название учебного года обязательно";
|
||||
}
|
||||
if (request.startDate() == null || request.endDate() == null) {
|
||||
return "Даты начала и окончания учебного года обязательны";
|
||||
}
|
||||
if (request.endDate().isBefore(request.startDate())) {
|
||||
return "Дата окончания не может быть раньше даты начала";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String validateSemester(SemesterDto request) {
|
||||
if (request.semesterType() == null) {
|
||||
return "Тип семестра обязателен";
|
||||
}
|
||||
if (request.startDate() == null || request.endDate() == null) {
|
||||
return "Даты начала и окончания семестра обязательны";
|
||||
}
|
||||
if (request.endDate().isBefore(request.startDate())) {
|
||||
return "Дата окончания семестра не может быть раньше даты начала";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String validateActivityType(AcademicCalendarActivityTypeDto request) {
|
||||
if (request == null) {
|
||||
return "Передайте код активности";
|
||||
@@ -224,18 +149,6 @@ public class AcademicCalendarAdminController {
|
||||
return null;
|
||||
}
|
||||
|
||||
private void applyYear(AcademicYear year, AcademicYearDto request) {
|
||||
year.setTitle(request.title().trim());
|
||||
year.setStartDate(request.startDate());
|
||||
year.setEndDate(request.endDate());
|
||||
}
|
||||
|
||||
private void applySemester(Semester semester, SemesterDto request) {
|
||||
semester.setSemesterType(request.semesterType());
|
||||
semester.setStartDate(request.startDate());
|
||||
semester.setEndDate(request.endDate());
|
||||
}
|
||||
|
||||
private void applyActivityType(AcademicCalendarActivityType activityType, AcademicCalendarActivityTypeDto request) {
|
||||
activityType.setCode(request.code().trim());
|
||||
activityType.setName(request.name().trim());
|
||||
|
||||
@@ -4,7 +4,10 @@ import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.*;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.AcademicCalendarGridService;
|
||||
import com.magistr.app.service.AcademicStructureService;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import com.magistr.app.utils.AcademicCalendarTitleBuilder;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -23,35 +26,38 @@ public class AcademicCalendarController {
|
||||
|
||||
private final AcademicCalendarRepository calendarRepository;
|
||||
private final AcademicCalendarDayRepository calendarDayRepository;
|
||||
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
|
||||
private final AcademicYearRepository academicYearRepository;
|
||||
private final SpecialtiesRepository specialtiesRepository;
|
||||
private final SpecialtyProfileRepository profileRepository;
|
||||
private final EducationFormRepository educationFormRepository;
|
||||
private final SubjectRepository subjectRepository;
|
||||
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||
private final AcademicCalendarGridService calendarGridService;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
private final AcademicStructureService academicStructureService;
|
||||
|
||||
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
|
||||
AcademicCalendarDayRepository calendarDayRepository,
|
||||
AcademicCalendarActivityTypeRepository activityTypeRepository,
|
||||
AcademicYearRepository academicYearRepository,
|
||||
SpecialtiesRepository specialtiesRepository,
|
||||
SpecialtyProfileRepository profileRepository,
|
||||
EducationFormRepository educationFormRepository,
|
||||
SubjectRepository subjectRepository,
|
||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
AcademicCalendarGridService calendarGridService,
|
||||
ScheduleGeneratorService scheduleGeneratorService,
|
||||
AcademicStructureService academicStructureService) {
|
||||
this.calendarRepository = calendarRepository;
|
||||
this.calendarDayRepository = calendarDayRepository;
|
||||
this.activityTypeRepository = activityTypeRepository;
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.specialtiesRepository = specialtiesRepository;
|
||||
this.profileRepository = profileRepository;
|
||||
this.educationFormRepository = educationFormRepository;
|
||||
this.subjectRepository = subjectRepository;
|
||||
this.calendarSubjectRepository = calendarSubjectRepository;
|
||||
this.calendarGridService = calendarGridService;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
this.academicStructureService = academicStructureService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -84,17 +90,7 @@ public class AcademicCalendarController {
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<?> updateCalendar(@PathVariable Long id, @RequestBody AcademicCalendarDto request) {
|
||||
AcademicCalendar calendar = calendarRepository.findById(id).orElse(null);
|
||||
if (calendar == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
try {
|
||||
applyCalendar(calendar, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar)));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||
}
|
||||
return ResponseEntity.ok(toCalendarDto(academicStructureService.updateCalendar(id, request)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@@ -102,14 +98,9 @@ public class AcademicCalendarController {
|
||||
if (!calendarRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
try {
|
||||
calendarRepository.deleteById(id);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Календарный график удалён"));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", "Нельзя удалить график, который назначен учебным группам"));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/grid")
|
||||
@@ -123,27 +114,10 @@ public class AcademicCalendarController {
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/grid")
|
||||
@Transactional
|
||||
public ResponseEntity<?> saveGrid(@PathVariable Long id, @RequestBody List<AcademicCalendarGridDayDto> rows) {
|
||||
AcademicCalendar calendar = calendarRepository.findByIdWithDetails(id).orElse(null);
|
||||
if (calendar == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Передайте хотя бы одну ячейку графика"));
|
||||
}
|
||||
|
||||
try {
|
||||
calendarDayRepository.deleteByCalendarId(id);
|
||||
for (AcademicCalendarGridDayDto row : rows) {
|
||||
AcademicCalendarDay day = buildGridDay(calendar, row);
|
||||
calendarDayRepository.save(day);
|
||||
}
|
||||
scheduleGeneratorService.clearCache();
|
||||
public ResponseEntity<?> saveGrid(@PathVariable("id") Long id,
|
||||
@RequestBody List<AcademicCalendarGridDayDto> rows) {
|
||||
calendarGridService.replaceGrid(id, rows);
|
||||
return ResponseEntity.ok(Map.of("message", "Календарный учебный график сохранён"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/subjects")
|
||||
@@ -179,9 +153,6 @@ public class AcademicCalendarController {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Передайте данные календарного графика");
|
||||
}
|
||||
if (request.title() == null || request.title().isBlank()) {
|
||||
throw new IllegalArgumentException("Название графика обязательно");
|
||||
}
|
||||
if (request.courseCount() == null || request.courseCount() <= 0) {
|
||||
throw new IllegalArgumentException("Количество курсов должно быть больше нуля");
|
||||
}
|
||||
@@ -205,7 +176,7 @@ public class AcademicCalendarController {
|
||||
EducationForm studyForm = educationFormRepository.findById(request.studyFormId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена"));
|
||||
|
||||
calendar.setTitle(request.title().trim());
|
||||
calendar.setTitle(AcademicCalendarTitleBuilder.build(year, speciality, profile, studyForm));
|
||||
calendar.setAcademicYear(year);
|
||||
calendar.setSpeciality(speciality);
|
||||
calendar.setSpecialtyProfile(profile);
|
||||
@@ -213,44 +184,6 @@ public class AcademicCalendarController {
|
||||
calendar.setCourseCount(request.courseCount());
|
||||
}
|
||||
|
||||
private AcademicCalendarDay buildGridDay(AcademicCalendar calendar, AcademicCalendarGridDayDto row) {
|
||||
if (row.courseNumber() == null || row.courseNumber() <= 0
|
||||
|| row.date() == null
|
||||
|| row.weekNumber() == null || row.weekNumber() <= 0
|
||||
|| row.dayOfWeek() == null || row.dayOfWeek() < 1 || row.dayOfWeek() > 7) {
|
||||
throw new IllegalArgumentException("Курс, дата, неделя и день недели обязательны");
|
||||
}
|
||||
if (row.date().isBefore(calendar.getAcademicYear().getStartDate())
|
||||
|| row.date().isAfter(calendar.getAcademicYear().getEndDate())) {
|
||||
throw new IllegalArgumentException("Дата выходит за пределы учебного года");
|
||||
}
|
||||
if (row.courseNumber() > calendar.getCourseCount()) {
|
||||
throw new IllegalArgumentException("Номер курса выходит за пределы графика");
|
||||
}
|
||||
|
||||
AcademicCalendarActivityType activityType = resolveActivityType(row);
|
||||
AcademicCalendarDay day = new AcademicCalendarDay();
|
||||
day.setAcademicCalendar(calendar);
|
||||
day.setCourseNumber(row.courseNumber());
|
||||
day.setDate(row.date());
|
||||
day.setWeekNumber(row.weekNumber());
|
||||
day.setDayOfWeek(row.dayOfWeek());
|
||||
day.setActivityType(activityType);
|
||||
return day;
|
||||
}
|
||||
|
||||
private AcademicCalendarActivityType resolveActivityType(AcademicCalendarGridDayDto row) {
|
||||
if (row.activityTypeId() != null) {
|
||||
return activityTypeRepository.findById(row.activityTypeId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Код активности не найден"));
|
||||
}
|
||||
if (row.activityCode() != null && !row.activityCode().isBlank()) {
|
||||
return activityTypeRepository.findByCode(row.activityCode().trim())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Код активности не найден"));
|
||||
}
|
||||
throw new IllegalArgumentException("Код активности обязателен");
|
||||
}
|
||||
|
||||
private void replaceCalendarSubjects(AcademicCalendar calendar, List<AcademicCalendarSubjectDto> rows) {
|
||||
if (rows.size() > 1000) {
|
||||
throw new IllegalArgumentException("Слишком много дисциплин для одного графика");
|
||||
|
||||
@@ -3,17 +3,18 @@ package com.magistr.app.controller;
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.JwtProperties;
|
||||
import com.magistr.app.config.auth.JwtTokenService;
|
||||
import com.magistr.app.config.auth.ClientIpResolver;
|
||||
import com.magistr.app.config.auth.LoginRateLimitService;
|
||||
import com.magistr.app.config.auth.RefreshTokenRotation;
|
||||
import com.magistr.app.config.auth.RefreshTokenService;
|
||||
import com.magistr.app.config.tenant.TenantContext;
|
||||
import com.magistr.app.dto.LoginRequest;
|
||||
import com.magistr.app.dto.LoginResponse;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -27,8 +28,8 @@ import java.util.Optional;
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
private final LoginRateLimitService loginRateLimitService;
|
||||
private final ClientIpResolver clientIpResolver;
|
||||
private final JwtTokenService jwtTokenService;
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
private final JwtProperties jwtProperties;
|
||||
@@ -42,13 +43,13 @@ public class AuthController {
|
||||
"STUDENT", "/student/"
|
||||
);
|
||||
|
||||
public AuthController(UserRepository userRepository,
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
public AuthController(LoginRateLimitService loginRateLimitService,
|
||||
ClientIpResolver clientIpResolver,
|
||||
JwtTokenService jwtTokenService,
|
||||
RefreshTokenService refreshTokenService,
|
||||
JwtProperties jwtProperties) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.loginRateLimitService = loginRateLimitService;
|
||||
this.clientIpResolver = clientIpResolver;
|
||||
this.jwtTokenService = jwtTokenService;
|
||||
this.refreshTokenService = refreshTokenService;
|
||||
this.jwtProperties = jwtProperties;
|
||||
@@ -56,28 +57,46 @@ public class AuthController {
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request, HttpServletRequest servletRequest) {
|
||||
Optional<User> userOpt = userRepository.findByUsername(request.getUsername());
|
||||
|
||||
if (userOpt.isEmpty() ||
|
||||
!passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) {
|
||||
LoginRateLimitService.AuthenticationDecision decision = loginRateLimitService.authenticate(
|
||||
TenantContext.getCurrentTenant(),
|
||||
request.getUsername(),
|
||||
request.getPassword(),
|
||||
clientIpResolver.resolve(servletRequest)
|
||||
);
|
||||
if (decision.status() == LoginRateLimitService.AuthenticationDecision.Status.BLOCKED) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.header(HttpHeaders.RETRY_AFTER, Long.toString(decision.retryAfterSeconds()))
|
||||
.body(new LoginResponse(
|
||||
false,
|
||||
"Слишком много попыток входа. Повторите позже",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
if (decision.status() == LoginRateLimitService.AuthenticationDecision.Status.REJECTED) {
|
||||
return ResponseEntity
|
||||
.status(401)
|
||||
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null, null));
|
||||
.body(new LoginResponse(
|
||||
false,
|
||||
"Неверное имя пользователя или пароль",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
User user = userOpt.get();
|
||||
if (user.isArchivedRecord()) {
|
||||
return ResponseEntity
|
||||
.status(401)
|
||||
.body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null));
|
||||
}
|
||||
User user = decision.user();
|
||||
String tenant = TenantContext.getCurrentTenant();
|
||||
String accessToken = jwtTokenService.createAccessToken(user, tenant);
|
||||
String refreshToken = refreshTokenService.createSession(
|
||||
user,
|
||||
tenant,
|
||||
servletRequest.getHeader("User-Agent"),
|
||||
clientIp(servletRequest)
|
||||
clientIpResolver.resolve(servletRequest)
|
||||
);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
@@ -96,7 +115,7 @@ public class AuthController {
|
||||
refreshToken.get(),
|
||||
TenantContext.getCurrentTenant(),
|
||||
request.getHeader("User-Agent"),
|
||||
clientIp(request)
|
||||
clientIpResolver.resolve(request)
|
||||
);
|
||||
if (rotation.isEmpty()) {
|
||||
return unauthorizedWithClearedCookie();
|
||||
@@ -192,11 +211,4 @@ public class AuthController {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private String clientIp(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import org.hibernate.exception.ConstraintViolationException;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Преобразует технические нарушения целостности БД в безопасные русские HTTP-ошибки.
|
||||
*/
|
||||
final class DatabaseConstraintViolationMapper {
|
||||
|
||||
private static final Pattern CONSTRAINT_PATTERN = Pattern.compile(
|
||||
"(?i)(?:constraint|ограничени[ея])\\s+[\"']?([a-z0-9_.$-]+)[\"']?"
|
||||
);
|
||||
|
||||
private static final Map<String, ViolationResponse> KNOWN_CONSTRAINTS = Map.ofEntries(
|
||||
badRequest("chk_teacher_creation_request_status", "Недопустимый статус заявки на преподавателя"),
|
||||
badRequest("chk_teacher_department_dates", "Дата окончания назначения кафедры раньше даты начала"),
|
||||
badRequest("chk_time_slot_scopes_mode", "Недопустимый режим сетки времени"),
|
||||
badRequest("chk_time_slot_scopes_day", "День недели сетки времени должен быть от 1 до 7"),
|
||||
badRequest("chk_time_slot_scopes_weekday", "День недели обязателен только для недельной сетки"),
|
||||
badRequest("chk_time_slots_order_positive", "Номер пары должен быть положительным"),
|
||||
badRequest("chk_time_slots_time_range", "Время начала пары должно быть раньше времени окончания"),
|
||||
badRequest("chk_time_slots_duration_positive", "Продолжительность пары должна быть положительной"),
|
||||
badRequest("chk_time_slots_duration_matches_range", "Продолжительность пары не соответствует её времени"),
|
||||
badRequest("chk_academic_years_dates", "Дата окончания учебного года раньше даты начала"),
|
||||
badRequest("chk_semesters_type", "Недопустимый тип семестра"),
|
||||
badRequest("chk_semesters_dates", "Дата окончания семестра раньше даты начала"),
|
||||
badRequest("chk_academic_calendars_course_count", "Количество курсов должно быть от 1 до 8"),
|
||||
badRequest("chk_calendar_days_course_positive", "Номер курса должен быть положительным"),
|
||||
badRequest("chk_calendar_days_week_positive", "Номер недели должен быть положительным"),
|
||||
badRequest("chk_calendar_days_day", "День недели должен быть от 1 до 7"),
|
||||
badRequest("chk_academic_calendar_subjects_semester_positive", "Номер семестра должен быть положительным"),
|
||||
badRequest("chk_student_groups_group_size_positive", "Численность группы должна быть больше нуля"),
|
||||
badRequest("chk_student_groups_year_start_positive", "Год начала обучения должен быть больше нуля"),
|
||||
badRequest("chk_subgroups_student_capacity_positive", "Численность подгруппы должна быть больше нуля"),
|
||||
badRequest("chk_student_group_subgroup_capacity", "Сумма численностей активных подгрупп не может превышать численность группы"),
|
||||
badRequest("chk_schedule_rules_type_hours_non_negative", "Количество академических часов не может быть отрицательным"),
|
||||
badRequest("chk_schedule_rules_has_type_hours", "В правиле должен быть хотя бы один тип занятия с часами"),
|
||||
badRequest("chk_schedule_rules_start_weeks_positive", "Неделя начала занятия должна быть положительной"),
|
||||
badRequest("chk_schedule_rules_dates", "Период действия правила задан неверно"),
|
||||
badRequest("chk_schedule_rules_academic_hours_even", "Количество академических часов должно быть чётным"),
|
||||
badRequest("chk_schedule_rule_slots_day", "День недели слота должен быть от 1 до 7"),
|
||||
badRequest("chk_schedule_rule_slots_parity", "Недопустимая чётность недели"),
|
||||
badRequest("chk_schedule_rule_slots_format", "Формат занятия должен быть «Очно» или «Онлайн»"),
|
||||
badRequest("chk_schedule_overrides_action", "Недопустимый тип точечного изменения расписания"),
|
||||
badRequest("chk_schedule_overrides_cancel_payload", "Для отмены переданы лишние параметры"),
|
||||
badRequest("chk_schedule_overrides_move_payload", "Для переноса переданы неверные параметры"),
|
||||
badRequest("chk_schedule_overrides_replace_payload", "Для замены преподавателя переданы неверные параметры"),
|
||||
badRequest("chk_schedule_overrides_format", "Недопустимый формат занятия в точечном изменении"),
|
||||
conflict("uq_specialty_profiles_name", "Профиль с таким названием уже существует"),
|
||||
conflict("uq_subjects_name_ci", "Дисциплина с таким названием уже существует"),
|
||||
conflict("uq_time_slot_scopes_default", "Базовая сетка времени уже существует"),
|
||||
conflict("uq_time_slot_scopes_weekday", "Сетка времени для этого дня недели уже существует"),
|
||||
conflict("uq_time_slots_scope_order", "Пара с таким номером уже существует в выбранной сетке"),
|
||||
conflict("uq_semesters_year_type", "Семестр такого типа уже существует в учебном году"),
|
||||
conflict("uq_academic_calendar_title", "Календарный график с таким названием уже существует"),
|
||||
conflict("uq_calendar_days", "День календарного графика уже существует"),
|
||||
conflict("uq_academic_calendar_subject", "Дисциплина уже добавлена в этот семестр графика"),
|
||||
conflict("uq_group_calendar_year", "Группе уже назначен календарный график на учебный год"),
|
||||
conflict("uq_schedule_rule_slots_exact_payload", "Такой слот правила расписания уже существует"),
|
||||
conflict("uq_schedule_overrides_slot_date", "Для этой пары и даты уже существует точечное изменение"),
|
||||
conflict("ux_subgroups_active_group_name", "Активная подгруппа с таким названием уже существует"),
|
||||
conflict("ex_time_slots_scope_no_overlap", "Интервал пары пересекается с другой парой выбранной сетки"),
|
||||
conflict("ex_academic_years_no_overlap", "Период учебного года пересекается с существующим"),
|
||||
conflict("ex_semesters_year_no_overlap", "Период семестра пересекается с существующим"),
|
||||
conflict("ex_teacher_primary_department_no_overlap", "Период основной кафедры пересекается с другим назначением")
|
||||
);
|
||||
|
||||
ViolationResponse map(DataIntegrityViolationException exception) {
|
||||
String constraintName = constraintName(exception).orElse(null);
|
||||
String sqlState = sqlState(exception).orElse(null);
|
||||
if (constraintName != null) {
|
||||
ViolationResponse known = KNOWN_CONSTRAINTS.get(constraintName);
|
||||
if (known != null) {
|
||||
return known.withDetails(constraintName, sqlState);
|
||||
}
|
||||
if (constraintName.endsWith("_fkey")) {
|
||||
return new ViolationResponse(
|
||||
HttpStatus.CONFLICT,
|
||||
"Операция невозможна из-за связанных данных",
|
||||
constraintName,
|
||||
sqlState
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ("23502".equals(sqlState)) {
|
||||
return new ViolationResponse(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Не заполнено обязательное поле",
|
||||
constraintName,
|
||||
sqlState
|
||||
);
|
||||
}
|
||||
if ("23514".equals(sqlState)) {
|
||||
return new ViolationResponse(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Данные нарушают правила предметной области",
|
||||
constraintName,
|
||||
sqlState
|
||||
);
|
||||
}
|
||||
if ("23505".equals(sqlState)) {
|
||||
return new ViolationResponse(
|
||||
HttpStatus.CONFLICT,
|
||||
"Такая запись уже существует",
|
||||
constraintName,
|
||||
sqlState
|
||||
);
|
||||
}
|
||||
if ("23503".equals(sqlState) || "23P01".equalsIgnoreCase(sqlState)) {
|
||||
return new ViolationResponse(
|
||||
HttpStatus.CONFLICT,
|
||||
"Операция невозможна из-за конфликта или связанных данных",
|
||||
constraintName,
|
||||
sqlState
|
||||
);
|
||||
}
|
||||
return new ViolationResponse(
|
||||
HttpStatus.CONFLICT,
|
||||
"Операция нарушает ограничения целостности данных",
|
||||
constraintName,
|
||||
sqlState
|
||||
);
|
||||
}
|
||||
|
||||
private Optional<String> constraintName(Throwable failure) {
|
||||
for (Throwable current = failure; current != null; current = nextCause(current)) {
|
||||
if (current instanceof ConstraintViolationException constraintViolation
|
||||
&& constraintViolation.getConstraintName() != null) {
|
||||
return normalize(constraintViolation.getConstraintName());
|
||||
}
|
||||
String message = current.getMessage();
|
||||
if (message != null) {
|
||||
Matcher matcher = CONSTRAINT_PATTERN.matcher(message);
|
||||
if (matcher.find()) {
|
||||
return normalize(matcher.group(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private Optional<String> sqlState(Throwable failure) {
|
||||
for (Throwable current = failure; current != null; current = nextCause(current)) {
|
||||
if (current instanceof SQLException sqlException && sqlException.getSQLState() != null) {
|
||||
return Optional.of(sqlException.getSQLState());
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private Throwable nextCause(Throwable current) {
|
||||
Throwable cause = current.getCause();
|
||||
return cause == current ? null : cause;
|
||||
}
|
||||
|
||||
private Optional<String> normalize(String constraintName) {
|
||||
if (constraintName == null || constraintName.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String normalized = constraintName.trim()
|
||||
.replace("\"", "")
|
||||
.toLowerCase(Locale.ROOT);
|
||||
int dot = normalized.lastIndexOf('.');
|
||||
return Optional.of(dot >= 0 ? normalized.substring(dot + 1) : normalized);
|
||||
}
|
||||
|
||||
private static Map.Entry<String, ViolationResponse> badRequest(String name, String message) {
|
||||
return Map.entry(name, new ViolationResponse(HttpStatus.BAD_REQUEST, message, name, "23514"));
|
||||
}
|
||||
|
||||
private static Map.Entry<String, ViolationResponse> conflict(String name, String message) {
|
||||
return Map.entry(name, new ViolationResponse(HttpStatus.CONFLICT, message, name, null));
|
||||
}
|
||||
|
||||
record ViolationResponse(HttpStatus status,
|
||||
String message,
|
||||
String constraintName,
|
||||
String sqlState) {
|
||||
|
||||
private ViolationResponse withDetails(String actualConstraintName, String actualSqlState) {
|
||||
return new ViolationResponse(status, message, actualConstraintName, actualSqlState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.config.tenant.ConfigMapUpdater;
|
||||
import com.magistr.app.config.tenant.TenantConfig;
|
||||
import com.magistr.app.config.tenant.TenantConfigWatcher;
|
||||
import com.magistr.app.config.tenant.TenantContext;
|
||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.service.TenantLifecycleException;
|
||||
import com.magistr.app.service.TenantLifecycleMutationResult;
|
||||
import com.magistr.app.service.TenantLifecycleService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -14,15 +17,14 @@ import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.NoSuchElementException;
|
||||
|
||||
/**
|
||||
* API управления подключениями к базам данных (тенантами).
|
||||
* Доступно только для ADMIN.
|
||||
*
|
||||
* При добавлении/удалении тенанта:
|
||||
* 1. Обновляется in-memory DataSource (мгновенно на этом поде)
|
||||
* 2. Обновляется K8s ConfigMap (через ConfigMapUpdater)
|
||||
* 3. Другие поды подхватят изменения через TenantConfigWatcher (~30 сек)
|
||||
* Изменения проходят безопасный lifecycle: подготовка и миграция временного
|
||||
* пула, сохранение desired-конфигурации и только затем локальная активация.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/database")
|
||||
@@ -30,15 +32,15 @@ import java.util.Map;
|
||||
public class DatabaseController {
|
||||
|
||||
private final TenantRoutingDataSource routingDataSource;
|
||||
private final ConfigMapUpdater configMapUpdater;
|
||||
private final TenantConfigWatcher configWatcher;
|
||||
private final TenantLifecycleService tenantLifecycleService;
|
||||
private final TenantConfigWatcher tenantConfigWatcher;
|
||||
|
||||
public DatabaseController(TenantRoutingDataSource routingDataSource,
|
||||
ConfigMapUpdater configMapUpdater,
|
||||
TenantConfigWatcher configWatcher) {
|
||||
TenantLifecycleService tenantLifecycleService,
|
||||
TenantConfigWatcher tenantConfigWatcher) {
|
||||
this.routingDataSource = routingDataSource;
|
||||
this.configMapUpdater = configMapUpdater;
|
||||
this.configWatcher = configWatcher;
|
||||
this.tenantLifecycleService = tenantLifecycleService;
|
||||
this.tenantConfigWatcher = tenantConfigWatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,41 +89,22 @@ public class DatabaseController {
|
||||
*/
|
||||
@PostMapping("/tenants")
|
||||
public ResponseEntity<Map<String, Object>> addTenant(@RequestBody TenantConfig config) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
if (config.getDomain() == null || config.getDomain().isBlank()) {
|
||||
result.put("success", false);
|
||||
result.put("message", "Домен не может быть пустым");
|
||||
return ResponseEntity.badRequest().body(result);
|
||||
}
|
||||
|
||||
if (config.getUrl() == null || config.getUrl().isBlank()) {
|
||||
result.put("success", false);
|
||||
result.put("message", "URL базы данных не может быть пустым");
|
||||
return ResponseEntity.badRequest().body(result);
|
||||
}
|
||||
|
||||
if (routingDataSource.hasTenant(config.getDomain())) {
|
||||
routingDataSource.removeTenant(config.getDomain());
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Добавить в in-memory (мгновенно на этом поде)
|
||||
routingDataSource.addTenant(config);
|
||||
|
||||
// 2. Инициализировать БД (init.sql) если нужно
|
||||
configWatcher.initDatabaseForTenant(config);
|
||||
|
||||
// 3. Обновить K8s ConfigMap (другие поды подхватят через ~30 сек)
|
||||
persistToConfigMap();
|
||||
|
||||
return tenantLifecycleService.executeSerialized(() -> {
|
||||
tenantConfigWatcher.prepareForApiMutation();
|
||||
TenantLifecycleMutationResult mutation =
|
||||
tenantLifecycleService.addOrUpdateTenantWithReceipt(config);
|
||||
tenantConfigWatcher.registerSuccessfulMutation(mutation.receipt());
|
||||
TenantConfig saved = mutation.tenant();
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("message", "Тенант '" + config.getDomain() + "' добавлен");
|
||||
result.put("message", "Тенант '" + saved.getDomain() + "' добавлен");
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
result.put("success", false);
|
||||
result.put("message", "Ошибка: " + e.getMessage());
|
||||
return ResponseEntity.internalServerError().body(result);
|
||||
});
|
||||
} catch (IllegalArgumentException validationFailure) {
|
||||
return lifecycleError(HttpStatus.BAD_REQUEST, validationFailure.getMessage());
|
||||
} catch (TenantLifecycleException lifecycleFailure) {
|
||||
return lifecycleError(HttpStatus.SERVICE_UNAVAILABLE, lifecycleFailure.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,21 +112,26 @@ public class DatabaseController {
|
||||
* Удалить тенант.
|
||||
*/
|
||||
@DeleteMapping("/tenants/{domain}")
|
||||
public ResponseEntity<Map<String, Object>> removeTenant(@PathVariable String domain) {
|
||||
public ResponseEntity<Map<String, Object>> removeTenant(@PathVariable("domain") String domain) {
|
||||
try {
|
||||
return tenantLifecycleService.executeSerialized(() -> {
|
||||
tenantConfigWatcher.prepareForApiMutation();
|
||||
TenantLifecycleMutationResult mutation =
|
||||
tenantLifecycleService.removeTenantWithReceipt(domain);
|
||||
tenantConfigWatcher.registerSuccessfulMutation(mutation.receipt());
|
||||
TenantConfig removed = mutation.tenant();
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
if (!routingDataSource.hasTenant(domain)) {
|
||||
result.put("success", false);
|
||||
result.put("message", "Тенант '" + domain + "' не найден");
|
||||
return ResponseEntity.status(404).body(result);
|
||||
}
|
||||
|
||||
routingDataSource.removeTenant(domain);
|
||||
persistToConfigMap();
|
||||
|
||||
result.put("success", true);
|
||||
result.put("message", "Тенант '" + domain + "' удалён");
|
||||
result.put("message", "Тенант '" + removed.getDomain() + "' удалён");
|
||||
return ResponseEntity.ok(result);
|
||||
});
|
||||
} catch (NoSuchElementException notFound) {
|
||||
return lifecycleError(HttpStatus.NOT_FOUND, notFound.getMessage());
|
||||
} catch (IllegalArgumentException validationFailure) {
|
||||
return lifecycleError(HttpStatus.BAD_REQUEST, validationFailure.getMessage());
|
||||
} catch (TenantLifecycleException lifecycleFailure) {
|
||||
return lifecycleError(HttpStatus.SERVICE_UNAVAILABLE, lifecycleFailure.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,14 +159,10 @@ public class DatabaseController {
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет текущий список тенантов в K8s ConfigMap.
|
||||
*/
|
||||
private void persistToConfigMap() {
|
||||
List<TenantConfig> tenants = new ArrayList<>(routingDataSource.getTenantConfigs().values());
|
||||
boolean ok = configMapUpdater.updateTenantsConfig(tenants);
|
||||
if (ok) {
|
||||
configWatcher.refreshHash(); // Чтобы watcher не перезагрузил те же данные
|
||||
}
|
||||
private ResponseEntity<Map<String, Object>> lifecycleError(HttpStatus status, String message) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", false);
|
||||
result.put("message", message);
|
||||
return ResponseEntity.status(status).body(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import com.magistr.app.model.Role;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -93,9 +92,8 @@ public class DepartmentController {
|
||||
)
|
||||
);
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при создании кафедры: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при создании кафедры " + e.getMessage()));
|
||||
logger.error("Ошибка при создании кафедры", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,9 +146,8 @@ public class DepartmentController {
|
||||
department.getDepartmentCode()
|
||||
));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при обновлении кафедры с ID - {}: {}", id, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при обновлении кафедры " + e.getMessage()));
|
||||
logger.error("Ошибка при обновлении кафедры с ID - {}", id, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ import com.magistr.app.repository.TeacherCreationRequestRepository;
|
||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.ScheduleQueryService;
|
||||
import com.magistr.app.service.SubjectImportService;
|
||||
import com.magistr.app.service.TeacherDepartmentService;
|
||||
import com.magistr.app.service.BusinessTimeService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -40,6 +44,9 @@ public class DepartmentWorkspaceController {
|
||||
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
||||
private final TeacherCreationRequestRepository teacherCreationRequestRepository;
|
||||
private final ScheduleQueryService scheduleQueryService;
|
||||
private final TeacherDepartmentService teacherDepartmentService;
|
||||
private final SubjectImportService subjectImportService;
|
||||
private final BusinessTimeService businessTime;
|
||||
|
||||
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
|
||||
SubjectCommentRepository subjectCommentRepository,
|
||||
@@ -47,7 +54,26 @@ public class DepartmentWorkspaceController {
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
||||
TeacherCreationRequestRepository teacherCreationRequestRepository,
|
||||
ScheduleQueryService scheduleQueryService) {
|
||||
ScheduleQueryService scheduleQueryService,
|
||||
TeacherDepartmentService teacherDepartmentService,
|
||||
SubjectImportService subjectImportService) {
|
||||
this(subjectRepository, subjectCommentRepository, userRepository, departmentRepository,
|
||||
teacherDepartmentAssignmentRepository, teacherCreationRequestRepository,
|
||||
scheduleQueryService, teacherDepartmentService, subjectImportService,
|
||||
BusinessTimeService.systemDefault());
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
|
||||
SubjectCommentRepository subjectCommentRepository,
|
||||
UserRepository userRepository,
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
||||
TeacherCreationRequestRepository teacherCreationRequestRepository,
|
||||
ScheduleQueryService scheduleQueryService,
|
||||
TeacherDepartmentService teacherDepartmentService,
|
||||
SubjectImportService subjectImportService,
|
||||
BusinessTimeService businessTime) {
|
||||
this.subjectRepository = subjectRepository;
|
||||
this.subjectCommentRepository = subjectCommentRepository;
|
||||
this.userRepository = userRepository;
|
||||
@@ -55,6 +81,9 @@ public class DepartmentWorkspaceController {
|
||||
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
||||
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
|
||||
this.scheduleQueryService = scheduleQueryService;
|
||||
this.teacherDepartmentService = teacherDepartmentService;
|
||||
this.subjectImportService = subjectImportService;
|
||||
this.businessTime = businessTime;
|
||||
}
|
||||
|
||||
@GetMapping("/subjects")
|
||||
@@ -77,19 +106,7 @@ public class DepartmentWorkspaceController {
|
||||
if (departmentId == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
|
||||
}
|
||||
List<Subject> saved = requests.stream()
|
||||
.filter(request -> request.getName() != null && !request.getName().isBlank())
|
||||
.map(request -> {
|
||||
Subject subject = subjectRepository.findByName(request.getName().trim()).orElseGet(Subject::new);
|
||||
subject.setName(request.getName().trim());
|
||||
subject.setCode(request.getCode() == null || request.getCode().isBlank() ? null : request.getCode().trim());
|
||||
subject.setDepartmentId(departmentId);
|
||||
if (subject.isArchivedRecord()) {
|
||||
subject.restore();
|
||||
}
|
||||
return subjectRepository.save(subject);
|
||||
})
|
||||
.toList();
|
||||
List<Subject> saved = subjectImportService.importSubjects(requests, departmentId);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"message", "Дисциплины загружены",
|
||||
"count", saved.size()
|
||||
@@ -118,6 +135,7 @@ public class DepartmentWorkspaceController {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Комментарий обязателен"));
|
||||
}
|
||||
SubjectComment comment = new SubjectComment();
|
||||
comment.setCreatedAt(businessTime.now());
|
||||
comment.setSubject(subject);
|
||||
comment.setComment(commentText.trim());
|
||||
Long currentUserId = AuthContext.currentUserId();
|
||||
@@ -132,21 +150,19 @@ public class DepartmentWorkspaceController {
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
if (effectiveDepartmentId == null) {
|
||||
return userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
|
||||
.map(this::toUserResponse)
|
||||
.map(user -> toUserResponse(
|
||||
user,
|
||||
teacherDepartmentService.findPrimaryDepartmentIdAtDate(
|
||||
user.getId(),
|
||||
businessTime.today()
|
||||
).orElse(user.getDepartmentId())
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Map<Long, User> teachersById = new LinkedHashMap<>();
|
||||
teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(effectiveDepartmentId, LocalDate.now())
|
||||
.stream()
|
||||
.map(TeacherDepartmentAssignment::getTeacher)
|
||||
.filter(Objects::nonNull)
|
||||
.filter(User::isActiveRecord)
|
||||
.forEach(teacher -> teachersById.put(teacher.getId(), teacher));
|
||||
userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED)
|
||||
.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
|
||||
return teachersById.values().stream()
|
||||
.map(this::toUserResponse)
|
||||
return teacherDepartmentService
|
||||
.findTeachersForDepartmentAtDate(effectiveDepartmentId, businessTime.today()).stream()
|
||||
.map(user -> toUserResponse(user, effectiveDepartmentId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -167,7 +183,7 @@ public class DepartmentWorkspaceController {
|
||||
if (teacher == null || teacher.getRole() != Role.TEACHER || teacher.isArchivedRecord()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Активный преподаватель не найден"));
|
||||
}
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDate today = businessTime.today();
|
||||
if (teacherDepartmentAssignmentRepository.existsActiveAssignment(teacherId, departmentId, today)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель уже привязан к этой кафедре"));
|
||||
}
|
||||
@@ -225,6 +241,8 @@ public class DepartmentWorkspaceController {
|
||||
}
|
||||
|
||||
TeacherCreationRequest teacherRequest = new TeacherCreationRequest();
|
||||
teacherRequest.setCreatedAt(businessTime.now());
|
||||
teacherRequest.setUpdatedAt(businessTime.now());
|
||||
teacherRequest.setDepartment(department);
|
||||
teacherRequest.setUsername(username);
|
||||
teacherRequest.setFullName(fullName);
|
||||
@@ -257,14 +275,14 @@ public class DepartmentWorkspaceController {
|
||||
);
|
||||
}
|
||||
|
||||
private UserResponse toUserResponse(User user) {
|
||||
private UserResponse toUserResponse(User user, Long departmentId) {
|
||||
UserResponse response = new UserResponse(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getRole().name(),
|
||||
user.getFullName(),
|
||||
user.getJobTitle(),
|
||||
user.getDepartmentId()
|
||||
departmentId
|
||||
);
|
||||
response.setStatus(user.getStatus());
|
||||
return response;
|
||||
|
||||
@@ -15,7 +15,7 @@ import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/education-forms")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public class EducationFormController {
|
||||
|
||||
private final EducationFormRepository educationFormRepository;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.service.ScheduleConflictException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
@@ -10,7 +12,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.NoSuchElementException;
|
||||
@@ -19,6 +21,17 @@ import java.util.NoSuchElementException;
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
private final DatabaseConstraintViolationMapper constraintViolationMapper =
|
||||
new DatabaseConstraintViolationMapper();
|
||||
|
||||
@ExceptionHandler(ScheduleConflictException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleScheduleConflict(ScheduleConflictException exception,
|
||||
HttpServletRequest request) {
|
||||
String message = exception.getMessage() == null || exception.getMessage().isBlank()
|
||||
? "Изменение создаёт конфликт расписания"
|
||||
: exception.getMessage();
|
||||
return error(HttpStatus.CONFLICT, message, request);
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleIllegalArgument(IllegalArgumentException exception,
|
||||
@@ -48,6 +61,23 @@ public class GlobalExceptionHandler {
|
||||
return error(HttpStatus.BAD_REQUEST, "Некорректные параметры запроса", request);
|
||||
}
|
||||
|
||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleDataIntegrityViolation(
|
||||
DataIntegrityViolationException exception,
|
||||
HttpServletRequest request) {
|
||||
DatabaseConstraintViolationMapper.ViolationResponse violation =
|
||||
constraintViolationMapper.map(exception);
|
||||
log.warn(
|
||||
"Нарушение целостности данных {} {}: constraint={}, sqlState={}",
|
||||
request.getMethod(),
|
||||
request.getRequestURI(),
|
||||
violation.constraintName() == null ? "не определено" : violation.constraintName(),
|
||||
violation.sqlState() == null ? "не определено" : violation.sqlState()
|
||||
);
|
||||
log.debug("Технические детали нарушения целостности данных", exception);
|
||||
return error(violation.status(), violation.message(), request);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Map<String, Object>> handleUnexpected(Exception exception,
|
||||
HttpServletRequest request) {
|
||||
@@ -57,7 +87,7 @@ public class GlobalExceptionHandler {
|
||||
|
||||
private ResponseEntity<Map<String, Object>> error(HttpStatus status, String message, HttpServletRequest request) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("timestamp", LocalDateTime.now().toString());
|
||||
body.put("timestamp", Instant.now().toString());
|
||||
body.put("status", status.value());
|
||||
body.put("error", reason(status));
|
||||
body.put("message", message);
|
||||
@@ -68,6 +98,7 @@ public class GlobalExceptionHandler {
|
||||
private String reason(HttpStatus status) {
|
||||
return switch (status) {
|
||||
case BAD_REQUEST -> "Некорректный запрос";
|
||||
case CONFLICT -> "Конфликт";
|
||||
case NOT_FOUND -> "Не найдено";
|
||||
case INTERNAL_SERVER_ERROR -> "Внутренняя ошибка сервера";
|
||||
default -> status.name();
|
||||
|
||||
@@ -5,10 +5,7 @@ import com.magistr.app.dto.AcademicCalendarSubjectDto;
|
||||
import com.magistr.app.dto.CreateGroupRequest;
|
||||
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
||||
import com.magistr.app.dto.GroupResponse;
|
||||
import com.magistr.app.model.AcademicCalendar;
|
||||
import com.magistr.app.model.AcademicCalendarSubject;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.EducationForm;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Speciality;
|
||||
import com.magistr.app.model.SpecialtyProfile;
|
||||
@@ -17,12 +14,15 @@ import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||
import com.magistr.app.model.StudentGroupStudyState;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.AcademicStructureService;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import com.magistr.app.service.StudentGroupLifecycleService;
|
||||
import com.magistr.app.service.BusinessTimeService;
|
||||
import com.magistr.app.utils.CourseAndSemesterCalculator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -30,7 +30,6 @@ import java.time.LocalDate;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -42,36 +41,39 @@ public class GroupController {
|
||||
private static final Logger logger = LoggerFactory.getLogger(GroupController.class);
|
||||
|
||||
private final GroupRepository groupRepository;
|
||||
private final EducationFormRepository educationFormRepository;
|
||||
private final SpecialtiesRepository specialtiesRepository;
|
||||
private final SpecialtyProfileRepository specialtyProfileRepository;
|
||||
private final AcademicYearRepository academicYearRepository;
|
||||
private final AcademicCalendarRepository academicCalendarRepository;
|
||||
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
private final StudentGroupLifecycleService groupLifecycleService;
|
||||
private final AcademicStructureService academicStructureService;
|
||||
private final BusinessTimeService businessTime;
|
||||
|
||||
public GroupController(GroupRepository groupRepository,
|
||||
EducationFormRepository educationFormRepository,
|
||||
SpecialtiesRepository specialtiesRepository,
|
||||
SpecialtyProfileRepository specialtyProfileRepository,
|
||||
AcademicYearRepository academicYearRepository,
|
||||
AcademicCalendarRepository academicCalendarRepository,
|
||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService,
|
||||
StudentGroupLifecycleService groupLifecycleService) {
|
||||
StudentGroupLifecycleService groupLifecycleService,
|
||||
AcademicStructureService academicStructureService) {
|
||||
this(groupRepository, calendarSubjectRepository, assignmentRepository,
|
||||
scheduleGeneratorService, groupLifecycleService, academicStructureService,
|
||||
BusinessTimeService.systemDefault());
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public GroupController(GroupRepository groupRepository,
|
||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService,
|
||||
StudentGroupLifecycleService groupLifecycleService,
|
||||
AcademicStructureService academicStructureService,
|
||||
BusinessTimeService businessTime) {
|
||||
this.groupRepository = groupRepository;
|
||||
this.educationFormRepository = educationFormRepository;
|
||||
this.specialtiesRepository = specialtiesRepository;
|
||||
this.specialtyProfileRepository = specialtyProfileRepository;
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.academicCalendarRepository = academicCalendarRepository;
|
||||
this.calendarSubjectRepository = calendarSubjectRepository;
|
||||
this.assignmentRepository = assignmentRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
this.groupLifecycleService = groupLifecycleService;
|
||||
this.academicStructureService = academicStructureService;
|
||||
this.businessTime = businessTime;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -82,7 +84,7 @@ public class GroupController {
|
||||
List<StudentGroup> groups = includeArchived
|
||||
? groupRepository.findAll()
|
||||
: groupRepository.findAll().stream()
|
||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
|
||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
|
||||
.toList();
|
||||
|
||||
List<GroupResponse> response = groups.stream()
|
||||
@@ -101,7 +103,7 @@ public class GroupController {
|
||||
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
|
||||
try {
|
||||
List<StudentGroup> groups = groupRepository.findByDepartmentId(departmentId).stream()
|
||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
|
||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
|
||||
.toList();
|
||||
|
||||
if(groups.isEmpty()) {
|
||||
@@ -127,97 +129,19 @@ public class GroupController {
|
||||
@PostMapping
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) {
|
||||
logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, yearStartStudy = {}",
|
||||
request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getYearStartStudy());
|
||||
try {
|
||||
ResponseEntity<?> validationError = validateGroupRequest(request);
|
||||
if (validationError != null) {
|
||||
return validationError;
|
||||
}
|
||||
|
||||
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
|
||||
if (efOpt.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена"));
|
||||
}
|
||||
Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId())
|
||||
.orElse(null);
|
||||
if (speciality == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Специальность не найдена"));
|
||||
}
|
||||
SpecialtyProfile profile = specialtyProfileRepository.findById(request.getSpecialtyProfileId())
|
||||
.orElse(null);
|
||||
if (profile == null || !profile.getSpeciality().getId().equals(speciality.getId())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Профиль обучения не найден для выбранной специальности"));
|
||||
}
|
||||
|
||||
StudentGroup group = new StudentGroup();
|
||||
group.setName(request.getName().trim());
|
||||
group.setGroupSize(request.getGroupSize());
|
||||
group.setEducationForm(efOpt.get());
|
||||
group.setDepartmentId(request.getDepartmentId());
|
||||
group.setYearStartStudy(request.getYearStartStudy());
|
||||
group.setSpeciality(speciality);
|
||||
group.setSpecialtyProfile(profile);
|
||||
groupRepository.save(group);
|
||||
|
||||
logger.info("Получен запрос на создание новой группы");
|
||||
StudentGroup group = academicStructureService.createGroup(request);
|
||||
logger.info("Группа успешно создана с ID - {}", group.getId());
|
||||
|
||||
return ResponseEntity.ok(mapToResponse(group));
|
||||
} catch (Exception e ) {
|
||||
logger.error("Ошибка при создании группы: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при создании группы: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public ResponseEntity<?> updateGroup(@PathVariable Long id, @RequestBody CreateGroupRequest request) {
|
||||
logger.info("Получен запрос на обновление группы с ID - {}", id);
|
||||
try {
|
||||
Optional<StudentGroup> groupOpt = groupRepository.findById(id);
|
||||
if (groupOpt.isEmpty()) {
|
||||
logger.info("Группа с ID - {} не найдена", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
ResponseEntity<?> validationError = validateGroupRequest(request);
|
||||
if (validationError != null) {
|
||||
return validationError;
|
||||
}
|
||||
|
||||
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
|
||||
if (efOpt.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена"));
|
||||
}
|
||||
Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId())
|
||||
.orElse(null);
|
||||
if (speciality == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Специальность не найдена"));
|
||||
}
|
||||
SpecialtyProfile profile = specialtyProfileRepository.findById(request.getSpecialtyProfileId())
|
||||
.orElse(null);
|
||||
if (profile == null || !profile.getSpeciality().getId().equals(speciality.getId())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Профиль обучения не найден для выбранной специальности"));
|
||||
}
|
||||
|
||||
StudentGroup group = groupOpt.get();
|
||||
group.setName(request.getName().trim());
|
||||
group.setGroupSize(request.getGroupSize());
|
||||
group.setEducationForm(efOpt.get());
|
||||
group.setDepartmentId(request.getDepartmentId());
|
||||
group.setYearStartStudy(request.getYearStartStudy());
|
||||
group.setSpeciality(speciality);
|
||||
group.setSpecialtyProfile(profile);
|
||||
groupRepository.save(group);
|
||||
|
||||
StudentGroup group = academicStructureService.updateGroup(id, request);
|
||||
logger.info("Группа с ID - {} успешно обновлена", id);
|
||||
return ResponseEntity.ok(mapToResponse(group));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при обновлении группы с ID - {}: {}", id, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при обновлении группы: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@@ -229,7 +153,7 @@ public class GroupController {
|
||||
logger.info("Группа с ID - {} не найдена", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
group.archive("Группа архивирована");
|
||||
group.archive("Группа архивирована", businessTime.today(), businessTime.now());
|
||||
groupRepository.save(group);
|
||||
logger.info("Группа с ID - {} успешно архивирована", id);
|
||||
return ResponseEntity.ok(Map.of("message", "Группа архивирована"));
|
||||
@@ -247,42 +171,12 @@ public class GroupController {
|
||||
return ResponseEntity.ok(mapToResponse(group));
|
||||
}
|
||||
|
||||
private ResponseEntity<?> validateGroupRequest(CreateGroupRequest request) {
|
||||
if (request.getName() == null || request.getName().isBlank()) {
|
||||
return validationError("Название группы обязательно");
|
||||
}
|
||||
if (request.getGroupSize() == null) {
|
||||
return validationError("Численность группы обязательна");
|
||||
}
|
||||
if (request.getEducationFormId() == null) {
|
||||
return validationError("Форма обучения обязательна");
|
||||
}
|
||||
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
|
||||
return validationError("ID кафедры обязателен");
|
||||
}
|
||||
if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) {
|
||||
return validationError("Год начала обучения обязателен");
|
||||
}
|
||||
if (request.getEffectiveSpecialtyId() == null || request.getEffectiveSpecialtyId() == 0) {
|
||||
return validationError("Код специальности обязателен");
|
||||
}
|
||||
if (request.getSpecialtyProfileId() == null || request.getSpecialtyProfileId() == 0) {
|
||||
return validationError("Профиль обучения обязателен");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private ResponseEntity<?> validationError(String errorMessage) {
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
|
||||
private GroupResponse mapToResponse(StudentGroup group) {
|
||||
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy());
|
||||
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy());
|
||||
LocalDate today = businessTime.today();
|
||||
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), today);
|
||||
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy(), today);
|
||||
Speciality speciality = group.getSpeciality();
|
||||
SpecialtyProfile profile = group.getSpecialtyProfile();
|
||||
LocalDate today = LocalDate.now();
|
||||
StudentGroupStudyState studyState = groupLifecycleService.getStudyState(group, today);
|
||||
return new GroupResponse(
|
||||
group.getId(),
|
||||
@@ -323,42 +217,9 @@ public class GroupController {
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public ResponseEntity<?> saveCalendarAssignment(@PathVariable Long id,
|
||||
@RequestBody GroupCalendarAssignmentDto request) {
|
||||
if (request == null || request.academicYearId() == null || request.calendarId() == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год и календарный график обязательны"));
|
||||
}
|
||||
StudentGroup group = groupRepository.findById(id).orElse(null);
|
||||
if (group == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
AcademicYear academicYear = academicYearRepository.findById(request.academicYearId()).orElse(null);
|
||||
if (academicYear == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден"));
|
||||
}
|
||||
AcademicCalendar calendar = academicCalendarRepository.findByIdWithDetails(request.calendarId()).orElse(null);
|
||||
if (calendar == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Календарный график не найден"));
|
||||
}
|
||||
if (!calendar.getAcademicYear().getId().equals(academicYear.getId())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "График относится к другому учебному году"));
|
||||
}
|
||||
if (!calendar.getSpeciality().getId().equals(group.getSpeciality().getId())
|
||||
|| !calendar.getSpecialtyProfile().getId().equals(group.getSpecialtyProfile().getId())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует специальности и профилю группы"));
|
||||
}
|
||||
if (!calendar.getStudyForm().getId().equals(group.getEducationForm().getId())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует форме обучения группы"));
|
||||
}
|
||||
|
||||
StudentGroupCalendarAssignment assignment = assignmentRepository
|
||||
.findByGroupIdAndAcademicYearIdWithDetails(group.getId(), academicYear.getId())
|
||||
.orElseGet(StudentGroupCalendarAssignment::new);
|
||||
assignment.setStudentGroup(group);
|
||||
assignment.setAcademicYear(academicYear);
|
||||
assignment.setAcademicCalendar(calendar);
|
||||
scheduleGeneratorService.clearCache();
|
||||
StudentGroupCalendarAssignment saved = assignmentRepository.save(assignment);
|
||||
StudentGroupCalendarAssignment saved = academicStructureService.saveAssignment(id, request);
|
||||
List<AcademicCalendarSubjectDto> subjects = calendarSubjectRepository
|
||||
.findByCalendarIdWithSubject(calendar.getId())
|
||||
.findByCalendarIdWithSubject(saved.getAcademicCalendar().getId())
|
||||
.stream()
|
||||
.map(this::toCalendarSubjectDto)
|
||||
.toList();
|
||||
|
||||
@@ -2,7 +2,10 @@ package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.RenderedLessonDto;
|
||||
import com.magistr.app.dto.SemesterDto;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Semester;
|
||||
import com.magistr.app.repository.SemesterRepository;
|
||||
import com.magistr.app.service.ScheduleQueryService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -11,6 +14,8 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@@ -21,9 +26,20 @@ public class ScheduleController {
|
||||
private static final Logger logger = LoggerFactory.getLogger(ScheduleController.class);
|
||||
|
||||
private final ScheduleQueryService scheduleQueryService;
|
||||
private final SemesterRepository semesterRepository;
|
||||
|
||||
public ScheduleController(ScheduleQueryService scheduleQueryService) {
|
||||
public ScheduleController(ScheduleQueryService scheduleQueryService,
|
||||
SemesterRepository semesterRepository) {
|
||||
this.scheduleQueryService = scheduleQueryService;
|
||||
this.semesterRepository = semesterRepository;
|
||||
}
|
||||
|
||||
@GetMapping("/semesters")
|
||||
public List<SemesterDto> getSemesters() {
|
||||
return semesterRepository.findAll().stream()
|
||||
.sorted(Comparator.comparing(Semester::getStartDate).reversed())
|
||||
.map(this::toSemesterDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -54,4 +70,15 @@ public class ScheduleController {
|
||||
endDate
|
||||
));
|
||||
}
|
||||
|
||||
private SemesterDto toSemesterDto(Semester semester) {
|
||||
return new SemesterDto(
|
||||
semester.getId(),
|
||||
semester.getAcademicYear().getId(),
|
||||
semester.getAcademicYear().getTitle(),
|
||||
semester.getSemesterType(),
|
||||
semester.getStartDate(),
|
||||
semester.getEndDate()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,148 +1,57 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.ScheduleOverrideDto;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import com.magistr.app.dto.ScheduleOverrideAvailabilityDto;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.service.ScheduleOverrideService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/edu-office/schedule/overrides")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public class ScheduleOverrideController {
|
||||
|
||||
private final ScheduleOverrideRepository scheduleOverrideRepository;
|
||||
private final ScheduleRuleSlotRepository scheduleRuleSlotRepository;
|
||||
private final TimeSlotRepository timeSlotRepository;
|
||||
private final ClassroomRepository classroomRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
private final ScheduleOverrideService scheduleOverrideService;
|
||||
|
||||
public ScheduleOverrideController(ScheduleOverrideRepository scheduleOverrideRepository,
|
||||
ScheduleRuleSlotRepository scheduleRuleSlotRepository,
|
||||
TimeSlotRepository timeSlotRepository,
|
||||
ClassroomRepository classroomRepository,
|
||||
UserRepository userRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.scheduleOverrideRepository = scheduleOverrideRepository;
|
||||
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
|
||||
this.timeSlotRepository = timeSlotRepository;
|
||||
this.classroomRepository = classroomRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
public ScheduleOverrideController(ScheduleOverrideService scheduleOverrideService) {
|
||||
this.scheduleOverrideService = scheduleOverrideService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<ScheduleOverrideDto> getAll() {
|
||||
return scheduleOverrideRepository.findAll().stream()
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
public List<ScheduleOverrideDto> getAll(
|
||||
@RequestParam(required = false) LocalDate startDate,
|
||||
@RequestParam(required = false) LocalDate endDate
|
||||
) {
|
||||
return scheduleOverrideService.getAll(startDate, endDate);
|
||||
}
|
||||
|
||||
@GetMapping("/availability")
|
||||
public ScheduleOverrideAvailabilityDto getAvailability(
|
||||
@RequestParam Long baseRuleSlotId,
|
||||
@RequestParam LocalDate lessonDate
|
||||
) {
|
||||
return scheduleOverrideService.getAvailability(baseRuleSlotId, lessonDate);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> create(@RequestBody ScheduleOverrideDto request) {
|
||||
return save(null, request);
|
||||
return ResponseEntity.ok(scheduleOverrideService.create(request));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody ScheduleOverrideDto request) {
|
||||
if (!scheduleOverrideRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return save(id, request);
|
||||
return ResponseEntity.ok(scheduleOverrideService.update(id, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
if (!scheduleOverrideRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
scheduleOverrideRepository.deleteById(id);
|
||||
scheduleGeneratorService.clearCache();
|
||||
scheduleOverrideService.delete(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Точечное изменение расписания удалено"));
|
||||
}
|
||||
|
||||
private ResponseEntity<?> save(Long id, ScheduleOverrideDto request) {
|
||||
String validationError = validate(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
|
||||
ScheduleOverride override = id == null
|
||||
? scheduleOverrideRepository
|
||||
.findByBaseRuleSlotIdAndLessonDate(request.baseRuleSlotId(), request.lessonDate())
|
||||
.orElseGet(ScheduleOverride::new)
|
||||
: scheduleOverrideRepository.findById(id).orElseGet(ScheduleOverride::new);
|
||||
|
||||
override.setBaseRuleSlot(scheduleRuleSlotRepository.findById(request.baseRuleSlotId()).orElseThrow());
|
||||
override.setLessonDate(request.lessonDate());
|
||||
override.setAction(request.action());
|
||||
override.setNewTimeSlot(request.newTimeSlotId() == null ? null : timeSlotRepository.findById(request.newTimeSlotId()).orElse(null));
|
||||
override.setNewClassroom(request.newClassroomId() == null ? null : classroomRepository.findById(request.newClassroomId()).orElse(null));
|
||||
override.setNewTeacher(request.newTeacherId() == null ? null : userRepository.findById(request.newTeacherId()).orElse(null));
|
||||
override.setNewLessonFormat(clean(request.newLessonFormat()));
|
||||
override.setComment(clean(request.comment()));
|
||||
override.setCreatedBy(AuthContext.currentUserId());
|
||||
|
||||
ScheduleOverride saved = scheduleOverrideRepository.save(override);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toDto(saved));
|
||||
}
|
||||
|
||||
private String validate(ScheduleOverrideDto request) {
|
||||
if (request == null) {
|
||||
return "Передайте данные изменения расписания";
|
||||
}
|
||||
if (request.baseRuleSlotId() == null || scheduleRuleSlotRepository.findById(request.baseRuleSlotId()).isEmpty()) {
|
||||
return "Базовый слот расписания не найден";
|
||||
}
|
||||
if (request.lessonDate() == null) {
|
||||
return "Дата пары обязательна";
|
||||
}
|
||||
if (request.action() == null || !List.of("MOVE", "CANCEL", "REPLACE").contains(request.action())) {
|
||||
return "Недопустимое действие изменения расписания";
|
||||
}
|
||||
if (request.newClassroomId() != null) {
|
||||
Classroom classroom = classroomRepository.findById(request.newClassroomId()).orElse(null);
|
||||
if (classroom == null || classroom.isArchivedRecord() || Boolean.FALSE.equals(classroom.getIsAvailable())) {
|
||||
return "Активная аудитория не найдена";
|
||||
}
|
||||
}
|
||||
if (request.newTeacherId() != null) {
|
||||
User teacher = userRepository.findById(request.newTeacherId()).orElse(null);
|
||||
if (teacher == null || teacher.getRole() != Role.TEACHER || teacher.isArchivedRecord()) {
|
||||
return "Активный преподаватель не найден";
|
||||
}
|
||||
}
|
||||
if (request.newTimeSlotId() != null && timeSlotRepository.findById(request.newTimeSlotId()).isEmpty()) {
|
||||
return "Временной слот не найден";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private ScheduleOverrideDto toDto(ScheduleOverride override) {
|
||||
return new ScheduleOverrideDto(
|
||||
override.getId(),
|
||||
override.getBaseRuleSlot().getId(),
|
||||
override.getLessonDate(),
|
||||
override.getAction(),
|
||||
override.getNewTimeSlot() == null ? null : override.getNewTimeSlot().getId(),
|
||||
override.getNewClassroom() == null ? null : override.getNewClassroom().getId(),
|
||||
override.getNewTeacher() == null ? null : override.getNewTeacher().getId(),
|
||||
override.getNewLessonFormat(),
|
||||
override.getComment(),
|
||||
override.getCreatedBy(),
|
||||
override.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
private String clean(String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,663 +2,79 @@ package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.ScheduleRuleDto;
|
||||
import com.magistr.app.dto.ScheduleRuleSlotDto;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.service.ScheduleRuleConflictException;
|
||||
import com.magistr.app.service.ScheduleRuleService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/schedule-rules")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public class ScheduleRuleAdminController {
|
||||
|
||||
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
|
||||
private static final int ACADEMIC_HOURS_PER_SLOT = 2;
|
||||
private static final Set<ScheduleParity> VALID_SLOT_PARITIES = Set.of(
|
||||
ScheduleParity.BOTH,
|
||||
ScheduleParity.ODD,
|
||||
ScheduleParity.EVEN
|
||||
);
|
||||
private final ScheduleRuleService scheduleRuleService;
|
||||
|
||||
private final ScheduleRuleRepository scheduleRuleRepository;
|
||||
private final SubjectRepository subjectRepository;
|
||||
private final SemesterRepository semesterRepository;
|
||||
private final GroupRepository groupRepository;
|
||||
private final TimeSlotRepository timeSlotRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final ClassroomRepository classroomRepository;
|
||||
private final LessonTypesRepository lessonTypesRepository;
|
||||
private final SubgroupRepository subgroupRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
public ScheduleRuleAdminController(ScheduleRuleRepository scheduleRuleRepository,
|
||||
SubjectRepository subjectRepository,
|
||||
SemesterRepository semesterRepository,
|
||||
GroupRepository groupRepository,
|
||||
TimeSlotRepository timeSlotRepository,
|
||||
UserRepository userRepository,
|
||||
ClassroomRepository classroomRepository,
|
||||
LessonTypesRepository lessonTypesRepository,
|
||||
SubgroupRepository subgroupRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.scheduleRuleRepository = scheduleRuleRepository;
|
||||
this.subjectRepository = subjectRepository;
|
||||
this.semesterRepository = semesterRepository;
|
||||
this.groupRepository = groupRepository;
|
||||
this.timeSlotRepository = timeSlotRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.classroomRepository = classroomRepository;
|
||||
this.lessonTypesRepository = lessonTypesRepository;
|
||||
this.subgroupRepository = subgroupRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
public ScheduleRuleAdminController(ScheduleRuleService scheduleRuleService) {
|
||||
this.scheduleRuleService = scheduleRuleService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<ScheduleRuleDto> getAll(@RequestParam(required = false) Long semesterId,
|
||||
@RequestParam(required = false) Long groupId) {
|
||||
return scheduleRuleRepository.findAllWithDetails().stream()
|
||||
.filter(rule -> semesterId == null || Objects.equals(rule.getSemester().getId(), semesterId))
|
||||
.filter(rule -> groupId == null || rule.getGroups().stream().anyMatch(group -> Objects.equals(group.getId(), groupId)))
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
public List<ScheduleRuleDto> getAll(@RequestParam(name = "semesterId", required = false) Long semesterId,
|
||||
@RequestParam(name = "groupId", required = false) Long groupId) {
|
||||
return scheduleRuleService.getAll(semesterId, groupId);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<?> getById(@PathVariable Long id) {
|
||||
return scheduleRuleRepository.findByIdWithDetails(id)
|
||||
.<ResponseEntity<?>>map(rule -> ResponseEntity.ok(toDto(rule)))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
public ResponseEntity<ScheduleRuleDto> getById(@PathVariable("id") Long id) {
|
||||
return ResponseEntity.ok(scheduleRuleService.getById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Transactional
|
||||
public ResponseEntity<?> create(@RequestBody ScheduleRuleDto request) {
|
||||
String validationError = validateRule(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
|
||||
try {
|
||||
ScheduleRule rule = new ScheduleRule();
|
||||
applyRule(rule, request);
|
||||
ScheduleRuleConflict conflict = findConflict(request, rule.getSlots(), null);
|
||||
if (conflict != null) {
|
||||
return conflictResponse(conflict);
|
||||
}
|
||||
ScheduleRule saved = scheduleRuleRepository.save(rule);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toDto(saved));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||
return ResponseEntity.ok(scheduleRuleService.create(request));
|
||||
} catch (ScheduleRuleConflictException exception) {
|
||||
return conflictResponse(exception);
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Transactional
|
||||
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody ScheduleRuleDto request) {
|
||||
ScheduleRule rule = scheduleRuleRepository.findByIdWithDetails(id).orElse(null);
|
||||
if (rule == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
String validationError = validateRule(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
|
||||
public ResponseEntity<?> update(@PathVariable("id") Long id, @RequestBody ScheduleRuleDto request) {
|
||||
try {
|
||||
ScheduleRule candidate = new ScheduleRule();
|
||||
applyRule(candidate, request);
|
||||
ScheduleRuleConflict conflict = findConflict(request, candidate.getSlots(), rule.getId());
|
||||
if (conflict != null) {
|
||||
return conflictResponse(conflict);
|
||||
}
|
||||
applyRule(rule, request);
|
||||
ScheduleRule saved = scheduleRuleRepository.save(rule);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toDto(saved));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||
return ResponseEntity.ok(scheduleRuleService.update(id, request));
|
||||
} catch (ScheduleRuleConflictException exception) {
|
||||
return conflictResponse(exception);
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
ScheduleRule rule = scheduleRuleRepository.findById(id).orElse(null);
|
||||
if (rule == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
rule.setStatus(LifecycleEntity.STATUS_ARCHIVED);
|
||||
scheduleRuleRepository.save(rule);
|
||||
scheduleGeneratorService.clearCache();
|
||||
public ResponseEntity<Map<String, String>> delete(@PathVariable("id") Long id) {
|
||||
scheduleRuleService.archive(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Правило расписания архивировано"));
|
||||
}
|
||||
|
||||
private String validateRule(ScheduleRuleDto request) {
|
||||
if (request.subjectId() == null) {
|
||||
return "Дисциплина обязательна";
|
||||
}
|
||||
if (request.semesterId() == null) {
|
||||
return "Семестр обязателен";
|
||||
}
|
||||
String hoursValidationError = validateTypeHours(request);
|
||||
if (hoursValidationError != null) {
|
||||
return hoursValidationError;
|
||||
}
|
||||
if (request.groupIds() == null || request.groupIds().isEmpty()) {
|
||||
return "Нужно выбрать хотя бы одну группу";
|
||||
}
|
||||
if (request.slots() == null || request.slots().isEmpty()) {
|
||||
return "Добавьте хотя бы один слот занятия";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void applyRule(ScheduleRule rule, ScheduleRuleDto request) {
|
||||
Subject subject = subjectRepository.findById(request.subjectId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена"));
|
||||
if (subject.isArchivedRecord()) {
|
||||
throw new IllegalArgumentException("Архивную дисциплину нельзя использовать в новых правилах");
|
||||
}
|
||||
Semester semester = semesterRepository.findById(request.semesterId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
|
||||
List<StudentGroup> groups = groupRepository.findAllById(request.groupIds());
|
||||
if (groups.size() != new HashSet<>(request.groupIds()).size()) {
|
||||
throw new IllegalArgumentException("Одна или несколько групп не найдены");
|
||||
}
|
||||
if (groups.stream().anyMatch(StudentGroup::isArchivedRecord)) {
|
||||
throw new IllegalArgumentException("Архивные группы нельзя использовать в новых правилах");
|
||||
}
|
||||
|
||||
rule.setSubject(subject);
|
||||
rule.setSemester(semester);
|
||||
rule.setLectureAcademicHours(request.lectureAcademicHours());
|
||||
rule.setLaboratoryAcademicHours(request.laboratoryAcademicHours());
|
||||
rule.setPracticeAcademicHours(request.practiceAcademicHours());
|
||||
rule.setLectureStartWeek(request.lectureStartWeek());
|
||||
rule.setLaboratoryStartWeek(request.laboratoryStartWeek());
|
||||
rule.setPracticeStartWeek(request.practiceStartWeek());
|
||||
rule.getGroups().clear();
|
||||
rule.getGroups().addAll(groups);
|
||||
|
||||
List<ScheduleRuleSlot> slots = new ArrayList<>();
|
||||
for (ScheduleRuleSlotDto slotDto : request.slots()) {
|
||||
slots.add(buildSlot(rule, slotDto));
|
||||
}
|
||||
validateTypeCoverage(rule, slots);
|
||||
|
||||
rule.getSlots().clear();
|
||||
rule.getSlots().addAll(slots);
|
||||
}
|
||||
|
||||
private ResponseEntity<?> conflictResponse(ScheduleRuleConflict conflict) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of(
|
||||
"message", "Невозможно сохранить правило: слот занят",
|
||||
"conflictRule", toDto(conflict.rule()),
|
||||
"conflictFields", conflict.fields().stream().toList(),
|
||||
"conflictReasons", conflict.fields().stream().map(this::conflictFieldLabel).toList()
|
||||
));
|
||||
}
|
||||
|
||||
private String conflictFieldLabel(String field) {
|
||||
return switch (field) {
|
||||
case "teacher" -> "Преподаватель";
|
||||
case "classroom" -> "Аудитория";
|
||||
case "group" -> "Группа";
|
||||
default -> field;
|
||||
};
|
||||
}
|
||||
|
||||
private ScheduleRuleConflict findConflict(ScheduleRuleDto request, Collection<ScheduleRuleSlot> newSlots, Long excludeRuleId) {
|
||||
Map<ScheduleRuleSlot, Set<Integer>> newActiveWeeks = activeWeeksBySlot(newSlots);
|
||||
return scheduleRuleRepository.findAllWithDetails().stream()
|
||||
.filter(rule -> !Objects.equals(rule.getId(), excludeRuleId))
|
||||
.filter(rule -> rule.getSemester() != null && Objects.equals(rule.getSemester().getId(), request.semesterId()))
|
||||
.map(rule -> toConflict(rule, newSlots, newActiveWeeks, rule.getSlots()))
|
||||
.filter(Objects::nonNull)
|
||||
.min(Comparator.comparing(conflict -> conflict.rule().getId()))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private ScheduleRuleConflict toConflict(ScheduleRule existingRule,
|
||||
Collection<ScheduleRuleSlot> newSlots,
|
||||
Map<ScheduleRuleSlot, Set<Integer>> newActiveWeeks,
|
||||
Collection<ScheduleRuleSlot> existingSlots) {
|
||||
Map<ScheduleRuleSlot, Set<Integer>> existingActiveWeeks = activeWeeksBySlot(existingSlots);
|
||||
LinkedHashSet<String> fields = new LinkedHashSet<>();
|
||||
for (ScheduleRuleSlot newSlot : newSlots) {
|
||||
for (ScheduleRuleSlot existingSlot : existingSlots) {
|
||||
if (sameTimeSlot(newSlot, existingSlot)
|
||||
&& activeWeeksOverlap(newActiveWeeks.get(newSlot), existingActiveWeeks.get(existingSlot))) {
|
||||
fields.addAll(conflictFields(newSlot, existingSlot));
|
||||
}
|
||||
}
|
||||
}
|
||||
return fields.isEmpty() ? null : new ScheduleRuleConflict(existingRule, fields);
|
||||
}
|
||||
|
||||
private Set<String> conflictFields(ScheduleRuleSlot first, ScheduleRuleSlot second) {
|
||||
LinkedHashSet<String> fields = new LinkedHashSet<>();
|
||||
if (Objects.equals(teacherId(first), teacherId(second))) {
|
||||
fields.add("teacher");
|
||||
}
|
||||
if (Objects.equals(classroomId(first), classroomId(second))) {
|
||||
fields.add("classroom");
|
||||
}
|
||||
if (sameAudience(first, second)) {
|
||||
fields.add("group");
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private boolean sameTimeSlot(ScheduleRuleSlot first, ScheduleRuleSlot second) {
|
||||
return Objects.equals(first.getDayOfWeek(), second.getDayOfWeek())
|
||||
&& parityOverlaps(first.getParity(), second.getParity())
|
||||
&& Objects.equals(timeSlotId(first), timeSlotId(second));
|
||||
}
|
||||
|
||||
private Long teacherId(ScheduleRuleSlot slot) {
|
||||
return slot.getTeacher() == null ? null : slot.getTeacher().getId();
|
||||
}
|
||||
|
||||
private Long classroomId(ScheduleRuleSlot slot) {
|
||||
return slot.getClassroom() == null ? null : slot.getClassroom().getId();
|
||||
}
|
||||
|
||||
private Long timeSlotId(ScheduleRuleSlot slot) {
|
||||
return slot.getTimeSlot() == null ? null : slot.getTimeSlot().getId();
|
||||
}
|
||||
|
||||
private boolean parityOverlaps(ScheduleParity first, ScheduleParity second) {
|
||||
if (Objects.equals(first, second)) {
|
||||
return true;
|
||||
}
|
||||
return first == ScheduleParity.BOTH || second == ScheduleParity.BOTH;
|
||||
}
|
||||
|
||||
private boolean sameAudience(ScheduleRuleSlot first, ScheduleRuleSlot second) {
|
||||
Set<Long> firstGroupIds = groupIds(first);
|
||||
Set<Long> secondGroupIds = groupIds(second);
|
||||
firstGroupIds.retainAll(secondGroupIds);
|
||||
|
||||
for (Long groupId : firstGroupIds) {
|
||||
if (slotAppliesToWholeGroup(first, groupId) || slotAppliesToWholeGroup(second, groupId)) {
|
||||
return true;
|
||||
}
|
||||
Set<Long> firstSubgroupIds = subgroupIdsForGroup(first, groupId);
|
||||
Set<Long> secondSubgroupIds = subgroupIdsForGroup(second, groupId);
|
||||
firstSubgroupIds.retainAll(secondSubgroupIds);
|
||||
if (!firstSubgroupIds.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Set<Long> groupIds(ScheduleRuleSlot slot) {
|
||||
Set<Long> ids = new HashSet<>();
|
||||
if (slot.getScheduleRule() == null) {
|
||||
return ids;
|
||||
}
|
||||
for (StudentGroup group : slot.getScheduleRule().getGroups()) {
|
||||
ids.add(group.getId());
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private boolean slotAppliesToWholeGroup(ScheduleRuleSlot slot, Long groupId) {
|
||||
return subgroupIdsForGroup(slot, groupId).isEmpty();
|
||||
}
|
||||
|
||||
private Set<Long> subgroupIdsForGroup(ScheduleRuleSlot slot, Long groupId) {
|
||||
Set<Long> ids = new HashSet<>();
|
||||
for (Subgroup subgroup : slot.getSubgroups()) {
|
||||
if (subgroup.getStudentGroup() != null
|
||||
&& Objects.equals(subgroup.getStudentGroup().getId(), groupId)) {
|
||||
ids.add(subgroup.getId());
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private Map<ScheduleRuleSlot, Set<Integer>> activeWeeksBySlot(Collection<ScheduleRuleSlot> slots) {
|
||||
Map<ScheduleRuleSlot, Set<Integer>> activeWeeks = new IdentityHashMap<>();
|
||||
slots.forEach(slot -> activeWeeks.put(slot, new HashSet<>()));
|
||||
if (slots.isEmpty()) {
|
||||
return activeWeeks;
|
||||
}
|
||||
|
||||
ScheduleRule rule = slots.iterator().next().getScheduleRule();
|
||||
if (rule == null || rule.getSemester() == null) {
|
||||
return activeWeeks;
|
||||
}
|
||||
|
||||
int maxWeeks = maxWeeksForRule(rule);
|
||||
for (ScheduleLessonCategory category : ScheduleLessonCategory.values()) {
|
||||
int totalHours = rule.academicHoursFor(category);
|
||||
if (totalHours <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
List<ScheduleRuleSlot> categorySlots = slots.stream()
|
||||
.filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category)
|
||||
.sorted(this::compareSlotsForGeneration)
|
||||
.toList();
|
||||
if (categorySlots.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Map<String, Integer> consumedHours = new HashMap<>();
|
||||
int startWeek = Math.max(1, rule.startWeekFor(category));
|
||||
for (int week = startWeek; week <= maxWeeks; week++) {
|
||||
for (ScheduleRuleSlot slot : categorySlots) {
|
||||
if (!slotAppliesToWeek(slot, week)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (String scopeKey : consumptionScopes(slot, category)) {
|
||||
int consumed = consumedHours.getOrDefault(scopeKey, 0);
|
||||
if (consumed >= totalHours) {
|
||||
continue;
|
||||
}
|
||||
activeWeeks.get(slot).add(week);
|
||||
consumedHours.put(scopeKey, consumed + ACADEMIC_HOURS_PER_SLOT);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return activeWeeks;
|
||||
}
|
||||
|
||||
private boolean activeWeeksOverlap(Set<Integer> first, Set<Integer> second) {
|
||||
if (first == null || second == null || first.isEmpty() || second.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
Set<Integer> weeks = new HashSet<>(first);
|
||||
weeks.retainAll(second);
|
||||
return !weeks.isEmpty();
|
||||
}
|
||||
|
||||
private int maxWeeksForRule(ScheduleRule rule) {
|
||||
Semester semester = rule.getSemester();
|
||||
if (semester != null && semester.getStartDate() != null && semester.getEndDate() != null) {
|
||||
long days = Math.max(1, ChronoUnit.DAYS.between(semester.getStartDate(), semester.getEndDate()) + 1);
|
||||
return Math.max(1, (int) Math.ceil(days / 7.0));
|
||||
}
|
||||
return 18;
|
||||
}
|
||||
|
||||
private int compareSlotsForGeneration(ScheduleRuleSlot first, ScheduleRuleSlot second) {
|
||||
int dayCompare = Integer.compare(
|
||||
first.getDayOfWeek() == null ? 0 : first.getDayOfWeek(),
|
||||
second.getDayOfWeek() == null ? 0 : second.getDayOfWeek()
|
||||
);
|
||||
if (dayCompare != 0) {
|
||||
return dayCompare;
|
||||
}
|
||||
int orderCompare = Integer.compare(
|
||||
first.getTimeSlot() == null || first.getTimeSlot().getOrderNumber() == null ? 0 : first.getTimeSlot().getOrderNumber(),
|
||||
second.getTimeSlot() == null || second.getTimeSlot().getOrderNumber() == null ? 0 : second.getTimeSlot().getOrderNumber()
|
||||
);
|
||||
if (orderCompare != 0) {
|
||||
return orderCompare;
|
||||
}
|
||||
return Long.compare(first.getId() == null ? 0L : first.getId(), second.getId() == null ? 0L : second.getId());
|
||||
}
|
||||
|
||||
private boolean slotAppliesToWeek(ScheduleRuleSlot slot, int week) {
|
||||
ScheduleParity parity = slot.getParity();
|
||||
if (parity == null || parity == ScheduleParity.BOTH) {
|
||||
return true;
|
||||
}
|
||||
ScheduleParity weekParity = week % 2 == 0 ? ScheduleParity.EVEN : ScheduleParity.ODD;
|
||||
return parity == weekParity;
|
||||
}
|
||||
|
||||
private List<String> consumptionScopes(ScheduleRuleSlot slot, ScheduleLessonCategory category) {
|
||||
if (category == ScheduleLessonCategory.LABORATORY && !slot.getSubgroups().isEmpty()) {
|
||||
return slot.getSubgroups().stream()
|
||||
.map(subgroup -> "subgroup:" + subgroup.getId())
|
||||
.toList();
|
||||
}
|
||||
if (slot.getScheduleRule() == null || slot.getScheduleRule().getGroups().isEmpty()) {
|
||||
return List.of("rule");
|
||||
}
|
||||
return slot.getScheduleRule().getGroups().stream()
|
||||
.map(group -> "group:" + group.getId())
|
||||
.toList();
|
||||
}
|
||||
|
||||
private ScheduleRuleSlot buildSlot(ScheduleRule rule, ScheduleRuleSlotDto slotDto) {
|
||||
if (slotDto.dayOfWeek() == null || slotDto.dayOfWeek() < 1 || slotDto.dayOfWeek() > 7) {
|
||||
throw new IllegalArgumentException("День недели должен быть от 1 до 7");
|
||||
}
|
||||
if (slotDto.parity() == null || !VALID_SLOT_PARITIES.contains(slotDto.parity())) {
|
||||
throw new IllegalArgumentException("Чётность недели должна быть: каждая, нечётная или чётная");
|
||||
}
|
||||
TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
|
||||
if (timeSlot.getTimeSlotScope() == null
|
||||
|| !APPLY_MODE_DEFAULT.equals(timeSlot.getTimeSlotScope().getApplyMode())) {
|
||||
throw new IllegalArgumentException("В правилах расписания можно выбирать только базовые временные слоты");
|
||||
}
|
||||
User teacher = userRepository.findById(slotDto.teacherId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
|
||||
if (teacher.isArchivedRecord()) {
|
||||
throw new IllegalArgumentException("Архивного преподавателя нельзя назначать в расписание");
|
||||
}
|
||||
Classroom classroom = classroomRepository.findById(slotDto.classroomId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"));
|
||||
if (classroom.isArchivedRecord() || Boolean.FALSE.equals(classroom.getIsAvailable())) {
|
||||
throw new IllegalArgumentException("Аудитория недоступна для новых назначений");
|
||||
}
|
||||
LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
|
||||
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(lessonType);
|
||||
Set<Subgroup> subgroups = resolveSubgroups(rule, slotDto, category);
|
||||
if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) {
|
||||
throw new IllegalArgumentException("Формат занятия обязателен");
|
||||
}
|
||||
|
||||
ScheduleRuleSlot slot = new ScheduleRuleSlot();
|
||||
slot.setScheduleRule(rule);
|
||||
slot.setDayOfWeek(slotDto.dayOfWeek());
|
||||
slot.setParity(slotDto.parity());
|
||||
slot.setTimeSlot(timeSlot);
|
||||
slot.setSubgroups(subgroups);
|
||||
slot.setTeacher(teacher);
|
||||
slot.setClassroom(classroom);
|
||||
slot.setLessonType(lessonType);
|
||||
slot.setLessonFormat(slotDto.lessonFormat().trim());
|
||||
return slot;
|
||||
}
|
||||
|
||||
private Set<Subgroup> resolveSubgroups(ScheduleRule rule, ScheduleRuleSlotDto slotDto, ScheduleLessonCategory category) {
|
||||
LinkedHashSet<Long> subgroupIds = new LinkedHashSet<>();
|
||||
if (slotDto.subgroupIds() != null) {
|
||||
slotDto.subgroupIds().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.forEach(subgroupIds::add);
|
||||
}
|
||||
if (slotDto.subgroupId() != null) {
|
||||
subgroupIds.add(slotDto.subgroupId());
|
||||
}
|
||||
if (subgroupIds.isEmpty()) {
|
||||
return new LinkedHashSet<>();
|
||||
}
|
||||
|
||||
if (category != ScheduleLessonCategory.LABORATORY) {
|
||||
throw new IllegalArgumentException("Подгруппы можно выбирать только для лабораторных занятий");
|
||||
}
|
||||
|
||||
Set<Long> ruleGroupIds = rule.getGroups().stream()
|
||||
.map(StudentGroup::getId)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
Set<Long> subgroupGroupIds = new HashSet<>();
|
||||
LinkedHashSet<Subgroup> subgroups = new LinkedHashSet<>();
|
||||
|
||||
for (Long subgroupId : subgroupIds) {
|
||||
Subgroup subgroup = subgroupRepository.findById(subgroupId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Подгруппа не найдена"));
|
||||
Long subgroupGroupId = subgroup.getStudentGroup().getId();
|
||||
if (!ruleGroupIds.contains(subgroupGroupId)) {
|
||||
throw new IllegalArgumentException("Подгруппа должна относиться к одной из групп правила");
|
||||
}
|
||||
if (!subgroupGroupIds.add(subgroupGroupId)) {
|
||||
throw new IllegalArgumentException("В одном слоте можно выбрать не больше одной подгруппы каждой группы");
|
||||
}
|
||||
subgroups.add(subgroup);
|
||||
}
|
||||
|
||||
return subgroups;
|
||||
}
|
||||
|
||||
private String validateTypeHours(ScheduleRuleDto request) {
|
||||
if (isMissing(request.lectureAcademicHours())
|
||||
|| isMissing(request.laboratoryAcademicHours())
|
||||
|| isMissing(request.practiceAcademicHours())) {
|
||||
return "Укажите количество часов для всех типов занятий";
|
||||
}
|
||||
if (isNegative(request.lectureAcademicHours())
|
||||
|| isNegative(request.laboratoryAcademicHours())
|
||||
|| isNegative(request.practiceAcademicHours())) {
|
||||
return "Количество часов по типам занятий не может быть отрицательным";
|
||||
}
|
||||
if (isInvalidStartWeek(request.lectureStartWeek())
|
||||
|| isInvalidStartWeek(request.laboratoryStartWeek())
|
||||
|| isInvalidStartWeek(request.practiceStartWeek())) {
|
||||
return "Неделя начала занятий должна быть больше нуля";
|
||||
}
|
||||
int totalHours = valueOrZero(request.lectureAcademicHours())
|
||||
+ valueOrZero(request.laboratoryAcademicHours())
|
||||
+ valueOrZero(request.practiceAcademicHours());
|
||||
if (totalHours <= 0) {
|
||||
return "Укажите часы хотя бы для одного типа занятий";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void validateTypeCoverage(ScheduleRule rule, List<ScheduleRuleSlot> slots) {
|
||||
EnumSet<ScheduleLessonCategory> categoriesWithSlots = EnumSet.noneOf(ScheduleLessonCategory.class);
|
||||
for (ScheduleRuleSlot slot : slots) {
|
||||
categoriesWithSlots.add(ScheduleLessonCategory.fromLessonType(slot.getLessonType()));
|
||||
}
|
||||
|
||||
for (ScheduleLessonCategory category : ScheduleLessonCategory.values()) {
|
||||
int hours = rule.academicHoursFor(category);
|
||||
boolean hasSlots = categoriesWithSlots.contains(category);
|
||||
if (hours > 0 && !hasSlots) {
|
||||
throw new IllegalArgumentException("Для типа \"" + category.getDisplayName() + "\" добавьте хотя бы один слот");
|
||||
}
|
||||
if (hours == 0 && hasSlots) {
|
||||
throw new IllegalArgumentException("Для типа \"" + category.getDisplayName() + "\" укажите часы или удалите слоты этого типа");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isMissing(Integer value) {
|
||||
return value == null;
|
||||
}
|
||||
|
||||
private boolean isNegative(Integer value) {
|
||||
return value < 0;
|
||||
}
|
||||
|
||||
private boolean isInvalidStartWeek(Integer value) {
|
||||
return value == null || value <= 0;
|
||||
}
|
||||
|
||||
private int valueOrZero(Integer value) {
|
||||
return value == null ? 0 : value;
|
||||
}
|
||||
|
||||
private ScheduleRuleDto toDto(ScheduleRule rule) {
|
||||
List<StudentGroup> groups = rule.getGroups().stream()
|
||||
.sorted(Comparator.comparing(StudentGroup::getName))
|
||||
.toList();
|
||||
List<ScheduleRuleSlotDto> slots = rule.getSlots().stream()
|
||||
.sorted(Comparator
|
||||
.comparing(ScheduleRuleSlot::getDayOfWeek)
|
||||
.thenComparing(slot -> slot.getTimeSlot().getOrderNumber())
|
||||
.thenComparing(ScheduleRuleSlot::getId))
|
||||
.map(this::toSlotDto)
|
||||
.toList();
|
||||
|
||||
return new ScheduleRuleDto(
|
||||
rule.getId(),
|
||||
rule.getSubject().getId(),
|
||||
rule.getSubject().getName(),
|
||||
rule.getSemester().getId(),
|
||||
rule.getSemester().getAcademicYear().getTitle(),
|
||||
rule.getSemester().getSemesterType(),
|
||||
rule.getLectureAcademicHours(),
|
||||
rule.getLaboratoryAcademicHours(),
|
||||
rule.getPracticeAcademicHours(),
|
||||
rule.getLectureStartWeek(),
|
||||
rule.getLaboratoryStartWeek(),
|
||||
rule.getPracticeStartWeek(),
|
||||
groups.stream().map(StudentGroup::getId).toList(),
|
||||
groups.stream().map(StudentGroup::getName).toList(),
|
||||
slots
|
||||
);
|
||||
}
|
||||
|
||||
private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) {
|
||||
TimeSlot timeSlot = slot.getTimeSlot();
|
||||
List<Subgroup> sortedSubgroups = sortedSubgroups(slot);
|
||||
return new ScheduleRuleSlotDto(
|
||||
slot.getId(),
|
||||
slot.getDayOfWeek(),
|
||||
dayName(slot.getDayOfWeek()),
|
||||
slot.getParity(),
|
||||
timeSlot.getId(),
|
||||
timeSlot.getOrderNumber(),
|
||||
formatTimeSlot(timeSlot),
|
||||
sortedSubgroups.isEmpty() ? null : sortedSubgroups.get(0).getId(),
|
||||
sortedSubgroups.isEmpty() ? null : formatSubgroup(sortedSubgroups.get(0)),
|
||||
sortedSubgroups.stream().map(Subgroup::getId).toList(),
|
||||
sortedSubgroups.stream().map(this::formatSubgroup).toList(),
|
||||
slot.getTeacher().getId(),
|
||||
ScheduleGeneratorService.displayUserName(slot.getTeacher()),
|
||||
slot.getClassroom().getId(),
|
||||
slot.getClassroom().getName(),
|
||||
slot.getLessonType().getId(),
|
||||
slot.getLessonType().getLessonType(),
|
||||
slot.getLessonFormat()
|
||||
);
|
||||
}
|
||||
|
||||
private String dayName(Integer dayOfWeek) {
|
||||
if (dayOfWeek == null || dayOfWeek < 1 || dayOfWeek > 7) {
|
||||
return "Неизвестно";
|
||||
}
|
||||
return ScheduleGeneratorService.dayName(DayOfWeek.of(dayOfWeek));
|
||||
}
|
||||
|
||||
private String formatTimeSlot(TimeSlot timeSlot) {
|
||||
return timeSlot.getStartTime() + " - " + timeSlot.getEndTime();
|
||||
}
|
||||
|
||||
private String formatSubgroup(Subgroup subgroup) {
|
||||
return subgroup.getStudentGroup().getName() + ": " + subgroup.getName();
|
||||
}
|
||||
|
||||
private List<Subgroup> sortedSubgroups(ScheduleRuleSlot slot) {
|
||||
return slot.getSubgroups().stream()
|
||||
.sorted(Comparator
|
||||
.comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName())
|
||||
.thenComparing(Subgroup::getName))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private record ScheduleRuleConflict(ScheduleRule rule, LinkedHashSet<String> fields) {
|
||||
private ResponseEntity<Map<String, Object>> conflictResponse(ScheduleRuleConflictException exception) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("message", exception.getMessage());
|
||||
if (exception.getConflictRule() != null) {
|
||||
body.put("conflictRule", exception.getConflictRule());
|
||||
}
|
||||
body.put("conflictFields", exception.getConflictFields());
|
||||
body.put("conflictReasons", exception.getConflictReasons());
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import com.magistr.app.repository.SpecialtiesRepository;
|
||||
import com.magistr.app.repository.SpecialtyProfileRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -36,6 +35,7 @@ public class SpecialityController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public List<Speciality> getAllSpecialties(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||
logger.info("Получен запрос на получение списка специальностей");
|
||||
try {
|
||||
@@ -102,9 +102,8 @@ public class SpecialityController {
|
||||
)
|
||||
);
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при создании специальности: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при создании специальности " + e.getMessage()));
|
||||
logger.error("Ошибка при создании специальности", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,9 +157,8 @@ public class SpecialityController {
|
||||
speciality.getSpecialityCode()
|
||||
));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при обновлении специальности с ID - {}: {}", id, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при обновлении специальности " + e.getMessage()));
|
||||
logger.error("Ошибка при обновлении специальности с ID - {}", id, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +192,7 @@ public class SpecialityController {
|
||||
}
|
||||
|
||||
@GetMapping("/profiles")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public List<SpecialtyProfileDto> getAllProfiles() {
|
||||
logger.info("Получен запрос на получение всех профилей обучения");
|
||||
return specialtyProfileRepository.findAll().stream()
|
||||
@@ -204,6 +203,7 @@ public class SpecialityController {
|
||||
}
|
||||
|
||||
@GetMapping("/{specialtyId}/profiles")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public ResponseEntity<?> getProfiles(@PathVariable Long specialtyId) {
|
||||
if (!specialtiesRepository.existsById(specialtyId)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
@@ -255,14 +255,8 @@ public class SpecialityController {
|
||||
if (profile == null || !profile.getSpeciality().getId().equals(specialtyId)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
try {
|
||||
specialtyProfileRepository.delete(profile);
|
||||
return ResponseEntity.ok(Map.of("message", "Профиль обучения удалён"));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при удалении профиля обучения с ID - {}: {}", profileId, e.getMessage(), e);
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", "Нельзя удалить профиль, который используется группами или календарными графиками"));
|
||||
}
|
||||
}
|
||||
|
||||
private String validateProfile(Long specialtyId, Long profileId, SpecialtyProfileDto request) {
|
||||
|
||||
@@ -9,8 +9,8 @@ import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.ScheduleRuleSlotRepository;
|
||||
import com.magistr.app.repository.SubgroupRepository;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
@@ -54,8 +54,9 @@ public class SubgroupController {
|
||||
|
||||
@PostMapping("/api/groups/{groupId}/subgroups")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
@Transactional
|
||||
public ResponseEntity<?> create(@PathVariable Long groupId, @RequestBody SubgroupDto request) {
|
||||
StudentGroup group = groupRepository.findById(groupId).orElse(null);
|
||||
StudentGroup group = groupRepository.findByIdForUpdate(groupId).orElse(null);
|
||||
if (group == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
@@ -82,9 +83,14 @@ public class SubgroupController {
|
||||
|
||||
@PutMapping("/api/groups/{groupId}/subgroups/{id}")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
@Transactional
|
||||
public ResponseEntity<?> update(@PathVariable Long groupId,
|
||||
@PathVariable Long id,
|
||||
@RequestBody SubgroupDto request) {
|
||||
StudentGroup group = groupRepository.findByIdForUpdate(groupId).orElse(null);
|
||||
if (group == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null);
|
||||
if (subgroup == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
@@ -99,7 +105,7 @@ public class SubgroupController {
|
||||
if (duplicateName) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе"));
|
||||
}
|
||||
String capacityError = validateGroupCapacity(subgroup.getStudentGroup(), id, request.studentCapacity());
|
||||
String capacityError = validateGroupCapacity(group, id, request.studentCapacity());
|
||||
if (capacityError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", capacityError));
|
||||
}
|
||||
@@ -113,7 +119,11 @@ public class SubgroupController {
|
||||
|
||||
@DeleteMapping("/api/groups/{groupId}/subgroups/{id}")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
@Transactional
|
||||
public ResponseEntity<?> delete(@PathVariable Long groupId, @PathVariable Long id) {
|
||||
if (groupRepository.findByIdForUpdate(groupId).isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null);
|
||||
if (subgroup == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
@@ -125,14 +135,10 @@ public class SubgroupController {
|
||||
if (deleteValidationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", deleteValidationError));
|
||||
}
|
||||
try {
|
||||
subgroup.archive("Подгруппа архивирована");
|
||||
subgroupRepository.save(subgroup);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Подгруппа архивирована"));
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
|
||||
}
|
||||
}
|
||||
|
||||
private String validate(SubgroupDto request) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import com.magistr.app.repository.SubjectRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -87,7 +86,7 @@ public class SubjectController {
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (subjectRepository.findByName(request.getName().trim()).isPresent()) {
|
||||
if (subjectRepository.findByNameIgnoreCase(request.getName().trim()).isPresent()) {
|
||||
String errorMessage = "Дисциплина с таким названием уже существует";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
@@ -119,10 +118,9 @@ public class SubjectController {
|
||||
subject.getDepartmentId()
|
||||
)
|
||||
);
|
||||
} catch (Exception e){
|
||||
logger.error("Ошибка при создании дисциплины: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при создании дисциплины " + e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при создании дисциплины", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,13 +10,12 @@ import com.magistr.app.repository.DepartmentRepository;
|
||||
import com.magistr.app.repository.TeacherCreationRequestRepository;
|
||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.BusinessTimeService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -30,17 +29,20 @@ public class TeacherCreationRequestController {
|
||||
private final DepartmentRepository departmentRepository;
|
||||
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
private final BusinessTimeService businessTime;
|
||||
|
||||
public TeacherCreationRequestController(TeacherCreationRequestRepository teacherCreationRequestRepository,
|
||||
UserRepository userRepository,
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
||||
BCryptPasswordEncoder passwordEncoder) {
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
BusinessTimeService businessTime) {
|
||||
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.departmentRepository = departmentRepository;
|
||||
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.businessTime = businessTime;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -98,7 +100,8 @@ public class TeacherCreationRequestController {
|
||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||
assignment.setTeacher(teacher);
|
||||
assignment.setDepartment(department);
|
||||
assignment.setValidFrom(LocalDate.now());
|
||||
assignment.setValidFrom(businessTime.today());
|
||||
assignment.setCreatedAt(businessTime.now());
|
||||
assignment.setPrimaryAssignment(true);
|
||||
assignment.setComment("Создан по заявке кафедры #" + request.getId());
|
||||
assignment.setCreatedBy(AuthContext.currentUserId());
|
||||
@@ -110,7 +113,7 @@ public class TeacherCreationRequestController {
|
||||
request.setDepartment(department);
|
||||
request.setStatus(TeacherCreationRequestStatus.APPROVED);
|
||||
request.setReviewedBy(AuthContext.currentUserId());
|
||||
request.setReviewedAt(LocalDateTime.now());
|
||||
request.setReviewedAt(businessTime.now());
|
||||
request.setReviewComment(trimToNull(review.getReviewComment()));
|
||||
request.setCreatedTeacherId(teacher.getId());
|
||||
teacherCreationRequestRepository.save(request);
|
||||
@@ -132,7 +135,7 @@ public class TeacherCreationRequestController {
|
||||
}
|
||||
request.setStatus(TeacherCreationRequestStatus.REJECTED);
|
||||
request.setReviewedBy(AuthContext.currentUserId());
|
||||
request.setReviewedAt(LocalDateTime.now());
|
||||
request.setReviewedAt(businessTime.now());
|
||||
request.setReviewComment(trimToNull(review == null ? null : review.getReviewComment()));
|
||||
return ResponseEntity.ok(toResponse(teacherCreationRequestRepository.save(request)));
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.SubjectRepository;
|
||||
import com.magistr.app.repository.TeacherSubjectRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.TeacherDepartmentService;
|
||||
import com.magistr.app.service.BusinessTimeService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -25,13 +28,28 @@ public class TeacherSubjectController {
|
||||
private final TeacherSubjectRepository teacherSubjectRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final SubjectRepository subjectRepository;
|
||||
private final TeacherDepartmentService teacherDepartmentService;
|
||||
private final BusinessTimeService businessTime;
|
||||
|
||||
public TeacherSubjectController(TeacherSubjectRepository teacherSubjectRepository,
|
||||
UserRepository userRepository,
|
||||
SubjectRepository subjectRepository) {
|
||||
SubjectRepository subjectRepository,
|
||||
TeacherDepartmentService teacherDepartmentService) {
|
||||
this(teacherSubjectRepository, userRepository, subjectRepository,
|
||||
teacherDepartmentService, BusinessTimeService.systemDefault());
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public TeacherSubjectController(TeacherSubjectRepository teacherSubjectRepository,
|
||||
UserRepository userRepository,
|
||||
SubjectRepository subjectRepository,
|
||||
TeacherDepartmentService teacherDepartmentService,
|
||||
BusinessTimeService businessTime) {
|
||||
this.teacherSubjectRepository = teacherSubjectRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.subjectRepository = subjectRepository;
|
||||
this.teacherDepartmentService = teacherDepartmentService;
|
||||
this.businessTime = businessTime;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -111,7 +129,11 @@ public class TeacherSubjectController {
|
||||
return departmentId != null
|
||||
&& teacher != null
|
||||
&& subject != null
|
||||
&& departmentId.equals(teacher.getDepartmentId())
|
||||
&& teacherDepartmentService.hasAssignmentAtDate(
|
||||
teacher.getId(),
|
||||
departmentId,
|
||||
businessTime.today()
|
||||
)
|
||||
&& departmentId.equals(subject.getDepartmentId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@ import com.magistr.app.model.Role;
|
||||
import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
|
||||
import com.magistr.app.repository.TimeSlotRepository;
|
||||
import com.magistr.app.repository.TimeSlotScopeRepository;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import com.magistr.app.service.TimeSlotService;
|
||||
import com.magistr.app.service.EffectiveTimeSlotService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -35,13 +35,19 @@ public class TimeSlotAdminController {
|
||||
private final TimeSlotRepository timeSlotRepository;
|
||||
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
||||
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
|
||||
private final TimeSlotService timeSlotService;
|
||||
private final EffectiveTimeSlotService effectiveTimeSlotService;
|
||||
|
||||
public TimeSlotAdminController(TimeSlotRepository timeSlotRepository,
|
||||
TimeSlotScopeRepository timeSlotScopeRepository,
|
||||
TimeSlotDateAssignmentRepository dateAssignmentRepository) {
|
||||
TimeSlotDateAssignmentRepository dateAssignmentRepository,
|
||||
TimeSlotService timeSlotService,
|
||||
EffectiveTimeSlotService effectiveTimeSlotService) {
|
||||
this.timeSlotRepository = timeSlotRepository;
|
||||
this.timeSlotScopeRepository = timeSlotScopeRepository;
|
||||
this.dateAssignmentRepository = dateAssignmentRepository;
|
||||
this.timeSlotService = timeSlotService;
|
||||
this.effectiveTimeSlotService = effectiveTimeSlotService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -53,7 +59,7 @@ public class TimeSlotAdminController {
|
||||
|
||||
@GetMapping("/effective")
|
||||
public ResponseEntity<?> getEffective(@RequestParam LocalDate date) {
|
||||
return ResponseEntity.ok(effectiveSlots(date).stream().map(this::toDto).toList());
|
||||
return ResponseEntity.ok(effectiveTimeSlotService.findEffective(date).stream().map(this::toDto).toList());
|
||||
}
|
||||
|
||||
@GetMapping("/scopes")
|
||||
@@ -151,112 +157,18 @@ public class TimeSlotAdminController {
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> create(@RequestBody TimeSlotDto request) {
|
||||
String validationError = validate(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (hasDuplicate(request, null)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары"));
|
||||
}
|
||||
|
||||
TimeSlot timeSlot = new TimeSlot();
|
||||
apply(timeSlot, request);
|
||||
return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot)));
|
||||
return ResponseEntity.ok(toDto(timeSlotService.create(request)));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody TimeSlotDto request) {
|
||||
TimeSlot timeSlot = timeSlotRepository.findById(id).orElse(null);
|
||||
if (timeSlot == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
String validationError = validate(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (hasDuplicate(request, id)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары"));
|
||||
}
|
||||
|
||||
apply(timeSlot, request);
|
||||
return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot)));
|
||||
return ResponseEntity.ok(toDto(timeSlotService.update(id, request)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
if (!timeSlotRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
try {
|
||||
timeSlotRepository.deleteById(id);
|
||||
timeSlotService.delete(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Нельзя удалить слот, который используется в правилах расписания"));
|
||||
}
|
||||
}
|
||||
|
||||
private String validate(TimeSlotDto request) {
|
||||
if (request.orderNumber() == null || request.orderNumber() <= 0) {
|
||||
return "Номер пары должен быть больше нуля";
|
||||
}
|
||||
if (request.scopeId() == null || timeSlotScopeRepository.findById(request.scopeId()).isEmpty()) {
|
||||
return "Выберите сетку времени";
|
||||
}
|
||||
if (request.startTime() == null || request.endTime() == null) {
|
||||
return "Время начала и окончания обязательно";
|
||||
}
|
||||
if (!request.startTime().isBefore(request.endTime())) {
|
||||
return "Время начала должно быть раньше времени окончания";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void apply(TimeSlot timeSlot, TimeSlotDto request) {
|
||||
timeSlot.setOrderNumber(request.orderNumber());
|
||||
timeSlot.setTimeSlotScope(timeSlotScopeRepository.findById(request.scopeId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Сетка времени не найдена")));
|
||||
timeSlot.setStartTime(request.startTime());
|
||||
timeSlot.setEndTime(request.endTime());
|
||||
int duration = request.durationMinutes() != null && request.durationMinutes() > 0
|
||||
? request.durationMinutes()
|
||||
: (int) Duration.between(request.startTime(), request.endTime()).toMinutes();
|
||||
timeSlot.setDurationMinutes(duration);
|
||||
}
|
||||
|
||||
private boolean hasDuplicate(TimeSlotDto request, Long currentId) {
|
||||
return timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber(request.scopeId(), request.orderNumber())
|
||||
.filter(existing -> currentId == null || !existing.getId().equals(currentId))
|
||||
.isPresent();
|
||||
}
|
||||
|
||||
private List<TimeSlot> effectiveSlots(LocalDate date) {
|
||||
TimeSlotScope defaultScope = defaultScope();
|
||||
TimeSlotScope effectiveScope = effectiveScope(date);
|
||||
List<TimeSlot> defaultSlots = timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(defaultScope.getId());
|
||||
if (effectiveScope.getId().equals(defaultScope.getId())) {
|
||||
return defaultSlots;
|
||||
}
|
||||
|
||||
Map<Integer, TimeSlot> slotsByOrder = new LinkedHashMap<>();
|
||||
defaultSlots.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot));
|
||||
timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(effectiveScope.getId())
|
||||
.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot));
|
||||
return new ArrayList<>(slotsByOrder.values());
|
||||
}
|
||||
|
||||
private TimeSlotScope effectiveScope(LocalDate date) {
|
||||
return dateAssignmentRepository.findByDate(date)
|
||||
.map(TimeSlotDateAssignment::getTimeSlotScope)
|
||||
.or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek(
|
||||
APPLY_MODE_WEEKDAY,
|
||||
date.getDayOfWeek().getValue()))
|
||||
.orElseGet(this::defaultScope);
|
||||
}
|
||||
|
||||
private TimeSlotScope defaultScope() {
|
||||
return timeSlotScopeRepository.findFirstByApplyModeOrderByDisplayOrderAsc(APPLY_MODE_DEFAULT)
|
||||
.orElseThrow(() -> new IllegalStateException("Базовая сетка времени не настроена"));
|
||||
}
|
||||
|
||||
private int nextManualDisplayOrder() {
|
||||
|
||||
@@ -14,20 +14,20 @@ import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.TeacherDepartmentService;
|
||||
import com.magistr.app.service.BusinessTimeService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@@ -38,16 +38,33 @@ public class UserController {
|
||||
private final UserRepository userRepository;
|
||||
private final DepartmentRepository departmentRepository;
|
||||
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
||||
private final TeacherDepartmentService teacherDepartmentService;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
private final BusinessTimeService businessTime;
|
||||
|
||||
public UserController(UserRepository userRepository,
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository) {
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
||||
TeacherDepartmentService teacherDepartmentService) {
|
||||
this(userRepository, passwordEncoder, departmentRepository,
|
||||
teacherDepartmentAssignmentRepository, teacherDepartmentService,
|
||||
BusinessTimeService.systemDefault());
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public UserController(UserRepository userRepository,
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
||||
TeacherDepartmentService teacherDepartmentService,
|
||||
BusinessTimeService businessTime) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.departmentRepository = departmentRepository;
|
||||
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
||||
this.teacherDepartmentService = teacherDepartmentService;
|
||||
this.businessTime = businessTime;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -82,7 +99,10 @@ public class UserController {
|
||||
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
|
||||
}
|
||||
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
|
||||
List<User> users = teachersForDepartment(departmentId, LocalDate.now());
|
||||
List<User> users = teacherDepartmentService.findTeachersForDepartmentAtDate(
|
||||
departmentId,
|
||||
businessTime.today()
|
||||
);
|
||||
|
||||
if (users.isEmpty()) {
|
||||
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
|
||||
@@ -92,7 +112,7 @@ public class UserController {
|
||||
|
||||
logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
|
||||
return ResponseEntity.ok(users.stream()
|
||||
.map(this::toUserResponse)
|
||||
.map(user -> toUserResponse(user, departmentId))
|
||||
.toList());
|
||||
}
|
||||
|
||||
@@ -161,7 +181,7 @@ public class UserController {
|
||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||
assignment.setTeacher(user);
|
||||
assignment.setDepartment(requestedDepartment);
|
||||
assignment.setValidFrom(LocalDate.now());
|
||||
assignment.setValidFrom(businessTime.today());
|
||||
assignment.setPrimaryAssignment(true);
|
||||
assignment.setComment("Начальная кафедра при создании пользователя");
|
||||
assignment.setCreatedBy(AuthContext.currentUserId());
|
||||
@@ -180,7 +200,7 @@ public class UserController {
|
||||
logger.info("Пользователь с ID - {} не найден", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
user.archive("Пользователь архивирован");
|
||||
user.archive("Пользователь архивирован", businessTime.today(), businessTime.now());
|
||||
userRepository.save(user);
|
||||
logger.info("Пользователь с ID - {} успешно архивирован", id);
|
||||
return ResponseEntity.ok(Map.of("message", "Пользователь архивирован"));
|
||||
@@ -206,7 +226,11 @@ public class UserController {
|
||||
var currentUser = AuthContext.getCurrentUser();
|
||||
if (currentUser != null
|
||||
&& currentUser.role() == Role.DEPARTMENT
|
||||
&& !teacherDepartmentAssignmentRepository.existsActiveAssignment(id, currentUser.departmentId(), LocalDate.now())) {
|
||||
&& !teacherDepartmentService.hasAssignmentAtDate(
|
||||
id,
|
||||
currentUser.departmentId(),
|
||||
businessTime.today()
|
||||
)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("message", "Недостаточно прав для просмотра истории этого преподавателя"));
|
||||
}
|
||||
@@ -216,46 +240,13 @@ public class UserController {
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/department-transfer")
|
||||
@Transactional
|
||||
public ResponseEntity<?> transferTeacher(@PathVariable Long id, @RequestBody DepartmentTransferRequest request) {
|
||||
User teacher = userRepository.findById(id).orElse(null);
|
||||
if (teacher == null || teacher.getRole() != Role.TEACHER) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден"));
|
||||
}
|
||||
if (teacher.isArchivedRecord()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Архивного преподавателя нельзя перевести"));
|
||||
}
|
||||
if (request.departmentId() == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
|
||||
}
|
||||
Department department = departmentRepository.findById(request.departmentId()).orElse(null);
|
||||
if (department == null || department.isArchivedRecord()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
|
||||
}
|
||||
LocalDate validFrom = request.validFrom() == null ? LocalDate.now() : request.validFrom();
|
||||
|
||||
teacherDepartmentAssignmentRepository.findOpenPrimaryByTeacherId(id)
|
||||
.ifPresent(current -> {
|
||||
if (current.getValidFrom().isBefore(validFrom)) {
|
||||
current.setValidTo(validFrom.minusDays(1));
|
||||
teacherDepartmentAssignmentRepository.save(current);
|
||||
} else {
|
||||
teacherDepartmentAssignmentRepository.delete(current);
|
||||
}
|
||||
});
|
||||
|
||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||
assignment.setTeacher(teacher);
|
||||
assignment.setDepartment(department);
|
||||
assignment.setValidFrom(validFrom);
|
||||
assignment.setPrimaryAssignment(true);
|
||||
assignment.setComment(request.comment());
|
||||
assignment.setCreatedBy(AuthContext.currentUserId());
|
||||
teacherDepartmentAssignmentRepository.save(assignment);
|
||||
|
||||
teacher.setDepartmentId(department.getId());
|
||||
userRepository.save(teacher);
|
||||
|
||||
TeacherDepartmentAssignment assignment = teacherDepartmentService.transferTeacher(
|
||||
id,
|
||||
request,
|
||||
businessTime.today(),
|
||||
AuthContext.currentUserId()
|
||||
);
|
||||
return ResponseEntity.ok(toAssignmentDto(assignment));
|
||||
}
|
||||
|
||||
@@ -268,16 +259,25 @@ public class UserController {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
|
||||
}
|
||||
LocalDate effectiveDate = date == null ? LocalDate.now() : date;
|
||||
return ResponseEntity.ok(teachersForDepartment(departmentId, effectiveDate).stream()
|
||||
.map(this::toUserResponse)
|
||||
LocalDate effectiveDate = date == null ? businessTime.today() : date;
|
||||
return ResponseEntity.ok(teacherDepartmentService
|
||||
.findTeachersForDepartmentAtDate(departmentId, effectiveDate).stream()
|
||||
.map(user -> toUserResponse(user, departmentId))
|
||||
.toList());
|
||||
}
|
||||
|
||||
private UserResponse toUserResponse(User user) {
|
||||
String departmentName = user.getDepartmentId() == null
|
||||
Long departmentId = user.getRole() == Role.TEACHER
|
||||
? teacherDepartmentService.findPrimaryDepartmentIdAtDate(user.getId(), businessTime.today())
|
||||
.orElse(user.getDepartmentId())
|
||||
: user.getDepartmentId();
|
||||
return toUserResponse(user, departmentId);
|
||||
}
|
||||
|
||||
private UserResponse toUserResponse(User user, Long departmentId) {
|
||||
String departmentName = departmentId == null
|
||||
? null
|
||||
: departmentRepository.findById(user.getDepartmentId())
|
||||
: departmentRepository.findById(departmentId)
|
||||
.map(Department::getDepartmentName)
|
||||
.orElse("Неизвестно");
|
||||
UserResponse response = new UserResponse(
|
||||
@@ -288,7 +288,7 @@ public class UserController {
|
||||
user.getJobTitle(),
|
||||
departmentName
|
||||
);
|
||||
response.setDepartmentId(user.getDepartmentId());
|
||||
response.setDepartmentId(departmentId);
|
||||
response.setStatus(user.getStatus());
|
||||
return response;
|
||||
}
|
||||
@@ -307,23 +307,6 @@ public class UserController {
|
||||
);
|
||||
}
|
||||
|
||||
private List<User> teachersForDepartment(Long departmentId, LocalDate date) {
|
||||
Map<Long, User> teachersById = new LinkedHashMap<>();
|
||||
List<TeacherDepartmentAssignment> assignments = teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(departmentId, date);
|
||||
if (assignments != null) {
|
||||
assignments.stream()
|
||||
.map(TeacherDepartmentAssignment::getTeacher)
|
||||
.filter(Objects::nonNull)
|
||||
.filter(User::isActiveRecord)
|
||||
.forEach(teacher -> teachersById.put(teacher.getId(), teacher));
|
||||
}
|
||||
List<User> directTeachers = userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, departmentId, LifecycleEntity.STATUS_ARCHIVED);
|
||||
if (directTeachers != null) {
|
||||
directTeachers.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
|
||||
}
|
||||
return List.copyOf(teachersById.values());
|
||||
}
|
||||
|
||||
private boolean canAccessDepartment(Long departmentId) {
|
||||
var currentUser = AuthContext.getCurrentUser();
|
||||
return currentUser == null
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.ClassroomResponse;
|
||||
import com.magistr.app.dto.RenderedLessonDto;
|
||||
@@ -7,11 +8,13 @@ import com.magistr.app.dto.WorkloadSummaryDto;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.ClassroomRepository;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.ScheduleQueryService;
|
||||
import com.magistr.app.service.TeacherDepartmentService;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
@@ -27,17 +30,17 @@ public class WorkloadController {
|
||||
|
||||
private final ScheduleQueryService scheduleQueryService;
|
||||
private final ClassroomRepository classroomRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final DepartmentRepository departmentRepository;
|
||||
private final TeacherDepartmentService teacherDepartmentService;
|
||||
|
||||
public WorkloadController(ScheduleQueryService scheduleQueryService,
|
||||
ClassroomRepository classroomRepository,
|
||||
UserRepository userRepository,
|
||||
DepartmentRepository departmentRepository) {
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentService teacherDepartmentService) {
|
||||
this.scheduleQueryService = scheduleQueryService;
|
||||
this.classroomRepository = classroomRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.departmentRepository = departmentRepository;
|
||||
this.teacherDepartmentService = teacherDepartmentService;
|
||||
}
|
||||
|
||||
@GetMapping("/teachers")
|
||||
@@ -46,19 +49,11 @@ public class WorkloadController {
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@RequestParam(required = false) Long departmentId
|
||||
) {
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
|
||||
null, null, null, null, startDate, endDate);
|
||||
Map<Long, User> teachersById = userRepository.findAllById(lessons.stream()
|
||||
.map(RenderedLessonDto::teacherId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet()))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(User::getId, Function.identity()));
|
||||
return summarize(lessons, RenderedLessonDto::teacherId, RenderedLessonDto::teacherName,
|
||||
teacherId -> {
|
||||
User teacher = teachersById.get(teacherId);
|
||||
return teacher == null ? null : teacher.getDepartmentId();
|
||||
});
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
|
||||
effectiveDepartmentId, null, startDate, endDate
|
||||
);
|
||||
return summarizeTeacherWorkload(lessons, startDate, endDate);
|
||||
}
|
||||
|
||||
@GetMapping("/classrooms")
|
||||
@@ -67,8 +62,10 @@ public class WorkloadController {
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@RequestParam(required = false) Long departmentId
|
||||
) {
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
|
||||
null, null, null, null, startDate, endDate);
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
|
||||
effectiveDepartmentId, null, startDate, endDate
|
||||
);
|
||||
return summarize(lessons, RenderedLessonDto::classroomId, RenderedLessonDto::classroomName, ignored -> null);
|
||||
}
|
||||
|
||||
@@ -78,22 +75,20 @@ public class WorkloadController {
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@RequestParam(required = false) Long departmentId
|
||||
) {
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
|
||||
null, null, null, null, startDate, endDate);
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
|
||||
effectiveDepartmentId, null, startDate, endDate
|
||||
);
|
||||
Map<Long, String> departments = departmentRepository.findAll().stream()
|
||||
.collect(Collectors.toMap(Department::getId, Department::getDepartmentName));
|
||||
Map<Long, User> teachersById = userRepository.findAllById(lessons.stream()
|
||||
.map(RenderedLessonDto::teacherId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet()))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(User::getId, Function.identity()));
|
||||
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher =
|
||||
assignmentsByTeacher(lessons, startDate, endDate);
|
||||
|
||||
Map<Long, List<RenderedLessonDto>> byDepartment = lessons.stream()
|
||||
.collect(Collectors.groupingBy(lesson -> {
|
||||
User teacher = teachersById.get(lesson.teacherId());
|
||||
return teacher == null ? 0L : teacher.getDepartmentId();
|
||||
}));
|
||||
.collect(Collectors.groupingBy(lesson -> departmentIdAtLesson(
|
||||
lesson,
|
||||
assignmentsByTeacher
|
||||
).orElse(0L)));
|
||||
|
||||
return byDepartment.entrySet().stream()
|
||||
.map(entry -> summary(entry.getKey(), departments.getOrDefault(entry.getKey(), "Кафедра не указана"), entry.getKey(), entry.getValue()))
|
||||
@@ -101,14 +96,76 @@ public class WorkloadController {
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<WorkloadSummaryDto> summarizeTeacherWorkload(List<RenderedLessonDto> lessons,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher =
|
||||
assignmentsByTeacher(lessons, startDate, endDate);
|
||||
Map<TeacherDepartmentKey, List<RenderedLessonDto>> grouped = lessons.stream()
|
||||
.filter(lesson -> lesson.teacherId() != null)
|
||||
.collect(Collectors.groupingBy(
|
||||
lesson -> new TeacherDepartmentKey(
|
||||
lesson.teacherId(),
|
||||
departmentIdAtLesson(lesson, assignmentsByTeacher).orElse(null)
|
||||
),
|
||||
LinkedHashMap::new,
|
||||
Collectors.toList()
|
||||
));
|
||||
return grouped.entrySet().stream()
|
||||
.map(entry -> summary(
|
||||
entry.getKey().teacherId(),
|
||||
entry.getValue().get(0).teacherName(),
|
||||
entry.getKey().departmentId(),
|
||||
entry.getValue()
|
||||
))
|
||||
.sorted(Comparator
|
||||
.comparing(WorkloadSummaryDto::name, Comparator.nullsLast(String::compareTo))
|
||||
.thenComparing(
|
||||
WorkloadSummaryDto::departmentId,
|
||||
Comparator.nullsLast(Long::compareTo)
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher(
|
||||
List<RenderedLessonDto> lessons,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate
|
||||
) {
|
||||
Set<Long> teacherIds = lessons.stream()
|
||||
.map(RenderedLessonDto::teacherId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
return teacherDepartmentService.findAssignmentsForTeachersBetween(
|
||||
teacherIds,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
}
|
||||
|
||||
private Optional<Long> departmentIdAtLesson(
|
||||
RenderedLessonDto lesson,
|
||||
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher
|
||||
) {
|
||||
if (lesson.teacherId() == null || lesson.date() == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return teacherDepartmentService.resolveDepartmentIdAtDate(
|
||||
assignmentsByTeacher.getOrDefault(lesson.teacherId(), List.of()),
|
||||
lesson.date()
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/time-slots")
|
||||
public List<WorkloadSummaryDto> timeSlotWorkload(
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@RequestParam(required = false) Long departmentId
|
||||
) {
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
|
||||
null, null, null, null, startDate, endDate);
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
|
||||
effectiveDepartmentId, null, startDate, endDate
|
||||
);
|
||||
return summarize(lessons, RenderedLessonDto::timeSlotId,
|
||||
lesson -> lesson.timeSlotOrder() == null
|
||||
? "Пара"
|
||||
@@ -121,8 +178,10 @@ public class WorkloadController {
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
|
||||
@RequestParam Long timeSlotId
|
||||
) {
|
||||
Set<Long> busyClassroomIds = scheduleQueryService.search(null, null, null, null,
|
||||
null, null, timeSlotId, null, date, date)
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(null);
|
||||
Set<Long> busyClassroomIds = scheduleQueryService.searchForAggregation(
|
||||
effectiveDepartmentId, timeSlotId, date, date
|
||||
)
|
||||
.stream()
|
||||
.map(RenderedLessonDto::classroomId)
|
||||
.filter(Objects::nonNull)
|
||||
@@ -166,6 +225,9 @@ public class WorkloadController {
|
||||
if (departmentId == null) {
|
||||
return null;
|
||||
}
|
||||
if (departmentId == 0L) {
|
||||
return "Кафедра не указана";
|
||||
}
|
||||
return departmentRepository.findById(departmentId)
|
||||
.map(Department::getDepartmentName)
|
||||
.orElse("Кафедра не найдена");
|
||||
@@ -185,4 +247,35 @@ public class WorkloadController {
|
||||
new ArrayList<>(classroom.getEquipments())
|
||||
);
|
||||
}
|
||||
|
||||
private Long effectiveDepartmentId(Long requestedDepartmentId) {
|
||||
var currentUser = AuthContext.getCurrentUser();
|
||||
if (currentUser == null) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
"Не удалось определить текущего пользователя"
|
||||
);
|
||||
}
|
||||
if (currentUser.role() != Role.DEPARTMENT) {
|
||||
return requestedDepartmentId;
|
||||
}
|
||||
|
||||
Long ownDepartmentId = currentUser.departmentId();
|
||||
if (ownDepartmentId == null) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"Для пользователя кафедры не указана кафедра"
|
||||
);
|
||||
}
|
||||
if (requestedDepartmentId != null && !Objects.equals(requestedDepartmentId, ownDepartmentId)) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"Нельзя просматривать нагрузку другой кафедры"
|
||||
);
|
||||
}
|
||||
return ownDepartmentId;
|
||||
}
|
||||
|
||||
private record TeacherDepartmentKey(Long teacherId, Long departmentId) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import com.magistr.app.model.Equipment;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public class ClassroomResponse {
|
||||
@@ -12,7 +12,7 @@ public class ClassroomResponse {
|
||||
private Integer floor;
|
||||
private Boolean isAvailable;
|
||||
private String status;
|
||||
private LocalDateTime archivedAt;
|
||||
private Instant archivedAt;
|
||||
private String archiveReason;
|
||||
private List<Equipment> equipments;
|
||||
|
||||
@@ -25,7 +25,7 @@ public class ClassroomResponse {
|
||||
}
|
||||
|
||||
public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor,
|
||||
Boolean isAvailable, String status, LocalDateTime archivedAt,
|
||||
Boolean isAvailable, String status, Instant archivedAt,
|
||||
String archiveReason, List<Equipment> equipments) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
@@ -95,11 +95,11 @@ public class ClassroomResponse {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public LocalDateTime getArchivedAt() {
|
||||
public Instant getArchivedAt() {
|
||||
return archivedAt;
|
||||
}
|
||||
|
||||
public void setArchivedAt(LocalDateTime archivedAt) {
|
||||
public void setArchivedAt(Instant archivedAt) {
|
||||
this.archivedAt = archivedAt;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,79 @@ public record RenderedLessonDto(
|
||||
Integer lessonTypeAcademicHours,
|
||||
Integer consumedLessonTypeAcademicHoursBeforeLesson,
|
||||
Integer remainingLessonTypeAcademicHoursAfterLesson,
|
||||
com.magistr.app.model.ScheduleParity ruleParity
|
||||
com.magistr.app.model.ScheduleParity ruleParity,
|
||||
Long scheduleOverrideId,
|
||||
String overrideAction,
|
||||
LocalDate originalLessonDate
|
||||
) {
|
||||
public RenderedLessonDto(
|
||||
Long scheduleRuleId,
|
||||
Long scheduleRuleSlotId,
|
||||
LocalDate date,
|
||||
Integer dayOfWeek,
|
||||
String dayName,
|
||||
Integer weekNumber,
|
||||
ScheduleParity parity,
|
||||
Long timeSlotId,
|
||||
Integer timeSlotOrder,
|
||||
LocalTime startTime,
|
||||
LocalTime endTime,
|
||||
Long subjectId,
|
||||
String subjectName,
|
||||
Long teacherId,
|
||||
String teacherName,
|
||||
Long classroomId,
|
||||
String classroomName,
|
||||
Long lessonTypeId,
|
||||
String lessonTypeName,
|
||||
String lessonFormat,
|
||||
Long subgroupId,
|
||||
String subgroupName,
|
||||
List<Long> subgroupIds,
|
||||
List<String> subgroupNames,
|
||||
List<Long> groupIds,
|
||||
List<String> groupNames,
|
||||
String activityType,
|
||||
Integer lessonTypeAcademicHours,
|
||||
Integer consumedLessonTypeAcademicHoursBeforeLesson,
|
||||
Integer remainingLessonTypeAcademicHoursAfterLesson,
|
||||
com.magistr.app.model.ScheduleParity ruleParity
|
||||
) {
|
||||
this(
|
||||
scheduleRuleId,
|
||||
scheduleRuleSlotId,
|
||||
date,
|
||||
dayOfWeek,
|
||||
dayName,
|
||||
weekNumber,
|
||||
parity,
|
||||
timeSlotId,
|
||||
timeSlotOrder,
|
||||
startTime,
|
||||
endTime,
|
||||
subjectId,
|
||||
subjectName,
|
||||
teacherId,
|
||||
teacherName,
|
||||
classroomId,
|
||||
classroomName,
|
||||
lessonTypeId,
|
||||
lessonTypeName,
|
||||
lessonFormat,
|
||||
subgroupId,
|
||||
subgroupName,
|
||||
subgroupIds,
|
||||
subgroupNames,
|
||||
groupIds,
|
||||
groupNames,
|
||||
activityType,
|
||||
lessonTypeAcademicHours,
|
||||
consumedLessonTypeAcademicHoursBeforeLesson,
|
||||
remainingLessonTypeAcademicHoursAfterLesson,
|
||||
ruleParity,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public record ScheduleOverrideAvailabilityDto(
|
||||
Long semesterId,
|
||||
LocalDate semesterStartDate,
|
||||
LocalDate semesterEndDate,
|
||||
List<LocalDate> availableDates
|
||||
) {
|
||||
}
|
||||
@@ -1,9 +1,85 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalTime;
|
||||
import java.util.List;
|
||||
|
||||
public record ScheduleOverrideDto(
|
||||
Long id,
|
||||
Long baseRuleSlotId,
|
||||
LocalDate lessonDate,
|
||||
LocalDate targetLessonDate,
|
||||
String action,
|
||||
Long newTimeSlotId,
|
||||
Long newClassroomId,
|
||||
Long newTeacherId,
|
||||
String newLessonFormat,
|
||||
String comment,
|
||||
Long createdBy,
|
||||
Instant createdAt,
|
||||
Long semesterId,
|
||||
LocalDate semesterStartDate,
|
||||
LocalDate semesterEndDate,
|
||||
Long sourceTimeSlotId,
|
||||
Integer sourceTimeSlotOrder,
|
||||
LocalTime sourceStartTime,
|
||||
LocalTime sourceEndTime,
|
||||
Long sourceTeacherId,
|
||||
String sourceTeacherName,
|
||||
Long sourceClassroomId,
|
||||
String sourceClassroomName,
|
||||
String sourceLessonFormat,
|
||||
String subjectName,
|
||||
String lessonTypeName,
|
||||
List<String> groupNames
|
||||
) {
|
||||
public ScheduleOverrideDto(
|
||||
Long id,
|
||||
Long baseRuleSlotId,
|
||||
LocalDate lessonDate,
|
||||
LocalDate targetLessonDate,
|
||||
String action,
|
||||
Long newTimeSlotId,
|
||||
Long newClassroomId,
|
||||
Long newTeacherId,
|
||||
String newLessonFormat,
|
||||
String comment,
|
||||
Long createdBy,
|
||||
Instant createdAt
|
||||
) {
|
||||
this(
|
||||
id,
|
||||
baseRuleSlotId,
|
||||
lessonDate,
|
||||
targetLessonDate,
|
||||
action,
|
||||
newTimeSlotId,
|
||||
newClassroomId,
|
||||
newTeacherId,
|
||||
newLessonFormat,
|
||||
comment,
|
||||
createdBy,
|
||||
createdAt,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
public ScheduleOverrideDto(
|
||||
Long id,
|
||||
Long baseRuleSlotId,
|
||||
LocalDate lessonDate,
|
||||
@@ -14,6 +90,36 @@ public record ScheduleOverrideDto(
|
||||
String newLessonFormat,
|
||||
String comment,
|
||||
Long createdBy,
|
||||
LocalDateTime createdAt
|
||||
) {
|
||||
Instant createdAt
|
||||
) {
|
||||
this(
|
||||
id,
|
||||
baseRuleSlotId,
|
||||
lessonDate,
|
||||
null,
|
||||
action,
|
||||
newTimeSlotId,
|
||||
newClassroomId,
|
||||
newTeacherId,
|
||||
newLessonFormat,
|
||||
comment,
|
||||
createdBy,
|
||||
createdAt,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
public record SubjectCommentDto(
|
||||
Long id,
|
||||
@@ -8,6 +8,6 @@ public record SubjectCommentDto(
|
||||
Long authorId,
|
||||
String authorName,
|
||||
String comment,
|
||||
LocalDateTime createdAt
|
||||
Instant createdAt
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
public record TeacherCreationRequestResponse(
|
||||
Long id,
|
||||
@@ -12,9 +12,9 @@ public record TeacherCreationRequestResponse(
|
||||
String comment,
|
||||
String status,
|
||||
Long requestedBy,
|
||||
LocalDateTime createdAt,
|
||||
Instant createdAt,
|
||||
Long reviewedBy,
|
||||
LocalDateTime reviewedAt,
|
||||
Instant reviewedAt,
|
||||
String reviewComment,
|
||||
Long createdTeacherId
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "auth_login_attempt_audit")
|
||||
public class AuthLoginAttemptAudit {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String tenant;
|
||||
|
||||
@Column(name = "username_normalized", nullable = false, length = 100)
|
||||
private String usernameNormalized;
|
||||
|
||||
@Column(name = "client_ip", nullable = false, length = 64)
|
||||
private String clientIp;
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
private String outcome;
|
||||
|
||||
@Column(name = "occurred_at", nullable = false)
|
||||
private Instant occurredAt;
|
||||
|
||||
@Column(name = "retry_after_seconds")
|
||||
private Integer retryAfterSeconds;
|
||||
|
||||
public AuthLoginAttemptAudit() {
|
||||
}
|
||||
|
||||
public AuthLoginAttemptAudit(String tenant,
|
||||
String usernameNormalized,
|
||||
String clientIp,
|
||||
String outcome,
|
||||
Instant occurredAt,
|
||||
Integer retryAfterSeconds) {
|
||||
this.tenant = tenant;
|
||||
this.usernameNormalized = usernameNormalized;
|
||||
this.clientIp = clientIp;
|
||||
this.outcome = outcome;
|
||||
this.occurredAt = occurredAt;
|
||||
this.retryAfterSeconds = retryAfterSeconds;
|
||||
}
|
||||
|
||||
public String getTenant() {
|
||||
return tenant;
|
||||
}
|
||||
|
||||
public String getUsernameNormalized() {
|
||||
return usernameNormalized;
|
||||
}
|
||||
|
||||
public String getClientIp() {
|
||||
return clientIp;
|
||||
}
|
||||
|
||||
public String getOutcome() {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
public Instant getOccurredAt() {
|
||||
return occurredAt;
|
||||
}
|
||||
|
||||
public Integer getRetryAfterSeconds() {
|
||||
return retryAfterSeconds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "auth_login_rate_limits")
|
||||
public class AuthLoginRateLimit {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String tenant;
|
||||
|
||||
@Column(name = "username_normalized", nullable = false, length = 100)
|
||||
private String usernameNormalized;
|
||||
|
||||
@Column(name = "client_ip", nullable = false, length = 64)
|
||||
private String clientIp;
|
||||
|
||||
@Column(name = "failure_count", nullable = false)
|
||||
private int failureCount;
|
||||
|
||||
@Column(name = "window_started_at", nullable = false)
|
||||
private Instant windowStartedAt;
|
||||
|
||||
@Column(name = "last_failure_at")
|
||||
private Instant lastFailureAt;
|
||||
|
||||
@Column(name = "blocked_until")
|
||||
private Instant blockedUntil;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public int getFailureCount() {
|
||||
return failureCount;
|
||||
}
|
||||
|
||||
public void setFailureCount(int failureCount) {
|
||||
this.failureCount = failureCount;
|
||||
}
|
||||
|
||||
public Instant getWindowStartedAt() {
|
||||
return windowStartedAt;
|
||||
}
|
||||
|
||||
public void setWindowStartedAt(Instant windowStartedAt) {
|
||||
this.windowStartedAt = windowStartedAt;
|
||||
}
|
||||
|
||||
public Instant getLastFailureAt() {
|
||||
return lastFailureAt;
|
||||
}
|
||||
|
||||
public void setLastFailureAt(Instant lastFailureAt) {
|
||||
this.lastFailureAt = lastFailureAt;
|
||||
}
|
||||
|
||||
public Instant getBlockedUntil() {
|
||||
return blockedUntil;
|
||||
}
|
||||
|
||||
public void setBlockedUntil(Instant blockedUntil) {
|
||||
this.blockedUntil = blockedUntil;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(Instant updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "auth_refresh_tokens")
|
||||
@@ -31,13 +31,13 @@ public class AuthRefreshToken {
|
||||
private String tokenHash;
|
||||
|
||||
@Column(name = "issued_at", nullable = false)
|
||||
private LocalDateTime issuedAt;
|
||||
private Instant issuedAt;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
private Instant expiresAt;
|
||||
|
||||
@Column(name = "revoked_at")
|
||||
private LocalDateTime revokedAt;
|
||||
private Instant revokedAt;
|
||||
|
||||
@Column(name = "rotated_to_token_hash", length = 64)
|
||||
private String rotatedToTokenHash;
|
||||
@@ -80,27 +80,27 @@ public class AuthRefreshToken {
|
||||
this.tokenHash = tokenHash;
|
||||
}
|
||||
|
||||
public LocalDateTime getIssuedAt() {
|
||||
public Instant getIssuedAt() {
|
||||
return issuedAt;
|
||||
}
|
||||
|
||||
public void setIssuedAt(LocalDateTime issuedAt) {
|
||||
public void setIssuedAt(Instant issuedAt) {
|
||||
this.issuedAt = issuedAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getExpiresAt() {
|
||||
public Instant getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public void setExpiresAt(LocalDateTime expiresAt) {
|
||||
public void setExpiresAt(Instant expiresAt) {
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getRevokedAt() {
|
||||
public Instant getRevokedAt() {
|
||||
return revokedAt;
|
||||
}
|
||||
|
||||
public void setRevokedAt(LocalDateTime revokedAt) {
|
||||
public void setRevokedAt(Instant revokedAt) {
|
||||
this.revokedAt = revokedAt;
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ public class AuthRefreshToken {
|
||||
this.ipAddress = ipAddress;
|
||||
}
|
||||
|
||||
public boolean isActive(LocalDateTime now) {
|
||||
public boolean isActive(Instant now) {
|
||||
return revokedAt == null && expiresAt.isAfter(now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import jakarta.persistence.Column;
|
||||
import jakarta.persistence.MappedSuperclass;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
|
||||
@MappedSuperclass
|
||||
public abstract class LifecycleEntity {
|
||||
@@ -23,7 +24,7 @@ public abstract class LifecycleEntity {
|
||||
private LocalDate activeTo;
|
||||
|
||||
@Column(name = "archived_at")
|
||||
private LocalDateTime archivedAt;
|
||||
private Instant archivedAt;
|
||||
|
||||
@Column(name = "archive_reason")
|
||||
private String archiveReason;
|
||||
@@ -52,11 +53,11 @@ public abstract class LifecycleEntity {
|
||||
this.activeTo = activeTo;
|
||||
}
|
||||
|
||||
public LocalDateTime getArchivedAt() {
|
||||
public Instant getArchivedAt() {
|
||||
return archivedAt;
|
||||
}
|
||||
|
||||
public void setArchivedAt(LocalDateTime archivedAt) {
|
||||
public void setArchivedAt(Instant archivedAt) {
|
||||
this.archivedAt = archivedAt;
|
||||
}
|
||||
|
||||
@@ -92,9 +93,13 @@ public abstract class LifecycleEntity {
|
||||
}
|
||||
|
||||
public void archive(String reason) {
|
||||
archive(reason, LocalDate.now(ZoneId.of("Europe/Moscow")), Instant.now());
|
||||
}
|
||||
|
||||
public void archive(String reason, LocalDate businessDate, Instant archiveMoment) {
|
||||
status = STATUS_ARCHIVED;
|
||||
archivedAt = LocalDateTime.now();
|
||||
activeTo = LocalDate.now();
|
||||
archivedAt = archiveMoment;
|
||||
activeTo = businessDate;
|
||||
archiveReason = reason == null || reason.isBlank() ? "Архивировано пользователем" : reason.trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.magistr.app.model;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "schedule_overrides")
|
||||
@@ -20,6 +20,9 @@ public class ScheduleOverride {
|
||||
@Column(name = "lesson_date", nullable = false)
|
||||
private LocalDate lessonDate;
|
||||
|
||||
@Column(name = "target_lesson_date")
|
||||
private LocalDate targetLessonDate;
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
private String action;
|
||||
|
||||
@@ -45,7 +48,7 @@ public class ScheduleOverride {
|
||||
private Long createdBy;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt = LocalDateTime.now();
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
@@ -67,6 +70,14 @@ public class ScheduleOverride {
|
||||
this.lessonDate = lessonDate;
|
||||
}
|
||||
|
||||
public LocalDate getTargetLessonDate() {
|
||||
return targetLessonDate;
|
||||
}
|
||||
|
||||
public void setTargetLessonDate(LocalDate targetLessonDate) {
|
||||
this.targetLessonDate = targetLessonDate;
|
||||
}
|
||||
|
||||
public String getAction() {
|
||||
return action;
|
||||
}
|
||||
@@ -123,11 +134,11 @@ public class ScheduleOverride {
|
||||
this.createdBy = createdBy;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ public class Subgroup extends LifecycleEntity {
|
||||
@Column(nullable = false, length = 100)
|
||||
private String name;
|
||||
|
||||
@Column(name = "student_capacity")
|
||||
@Column(name = "student_capacity", nullable = false)
|
||||
private Integer studentCapacity;
|
||||
|
||||
public Long getId() {
|
||||
|
||||
@@ -10,7 +10,7 @@ public class Subject extends LifecycleEntity {
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(unique = true, nullable = false, length = 200)
|
||||
@Column(nullable = false, length = 200)
|
||||
private String name;
|
||||
|
||||
@Column(name = "code")
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "subject_comments")
|
||||
@@ -24,7 +24,7 @@ public class SubjectComment {
|
||||
private String comment;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt = LocalDateTime.now();
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
@@ -54,11 +54,11 @@ public class SubjectComment {
|
||||
this.comment = comment;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "teacher_creation_requests")
|
||||
@@ -36,16 +36,16 @@ public class TeacherCreationRequest {
|
||||
private Long requestedBy;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt = LocalDateTime.now();
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt = LocalDateTime.now();
|
||||
private Instant updatedAt = Instant.now();
|
||||
|
||||
@Column(name = "reviewed_by")
|
||||
private Long reviewedBy;
|
||||
|
||||
@Column(name = "reviewed_at")
|
||||
private LocalDateTime reviewedAt;
|
||||
private Instant reviewedAt;
|
||||
|
||||
@Column(name = "review_comment", columnDefinition = "TEXT")
|
||||
private String reviewComment;
|
||||
@@ -55,7 +55,7 @@ public class TeacherCreationRequest {
|
||||
|
||||
@PreUpdate
|
||||
public void touch() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
@@ -118,19 +118,19 @@ public class TeacherCreationRequest {
|
||||
this.requestedBy = requestedBy;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getUpdatedAt() {
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||
public void setUpdatedAt(Instant updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
@@ -142,11 +142,11 @@ public class TeacherCreationRequest {
|
||||
this.reviewedBy = reviewedBy;
|
||||
}
|
||||
|
||||
public LocalDateTime getReviewedAt() {
|
||||
public Instant getReviewedAt() {
|
||||
return reviewedAt;
|
||||
}
|
||||
|
||||
public void setReviewedAt(LocalDateTime reviewedAt) {
|
||||
public void setReviewedAt(Instant reviewedAt) {
|
||||
this.reviewedAt = reviewedAt;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.magistr.app.model;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "teacher_department_assignments")
|
||||
@@ -34,7 +34,7 @@ public class TeacherDepartmentAssignment {
|
||||
private String comment;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt = LocalDateTime.now();
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
@Column(name = "created_by")
|
||||
private Long createdBy;
|
||||
@@ -91,11 +91,11 @@ public class TeacherDepartmentAssignment {
|
||||
this.comment = comment;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -35,8 +36,35 @@ public interface AcademicCalendarDayRepository extends JpaRepository<AcademicCal
|
||||
@Param("date") LocalDate date
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select day
|
||||
from AcademicCalendarDay day
|
||||
join fetch day.academicCalendar
|
||||
join fetch day.activityType
|
||||
where day.academicCalendar.id in :calendarIds
|
||||
and day.date between :startDate and :endDate
|
||||
order by day.academicCalendar.id, day.courseNumber, day.date
|
||||
""")
|
||||
List<AcademicCalendarDay> findForScheduleBatch(
|
||||
@Param("calendarIds") Collection<Long> calendarIds,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate
|
||||
);
|
||||
|
||||
boolean existsByActivityTypeId(Long activityTypeId);
|
||||
|
||||
boolean existsByAcademicCalendarIdAndCourseNumberGreaterThan(Long calendarId, Integer courseNumber);
|
||||
|
||||
@Query("""
|
||||
select case when count(day) > 0 then true else false end
|
||||
from AcademicCalendarDay day
|
||||
where day.academicCalendar.id = :calendarId
|
||||
and (day.date < :startDate or day.date > :endDate)
|
||||
""")
|
||||
boolean existsOutsideDateRange(@Param("calendarId") Long calendarId,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
|
||||
@Modifying
|
||||
@Query("delete from AcademicCalendarDay day where day.academicCalendar.id = :calendarId")
|
||||
void deleteByCalendarId(@Param("calendarId") Long calendarId);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.AcademicCalendar;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
@@ -40,4 +42,16 @@ public interface AcademicCalendarRepository extends JpaRepository<AcademicCalend
|
||||
where calendar.id = :id
|
||||
""")
|
||||
Optional<AcademicCalendar> findByIdWithDetails(@Param("id") Long id);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
select calendar
|
||||
from AcademicCalendar calendar
|
||||
join fetch calendar.academicYear
|
||||
join fetch calendar.speciality
|
||||
join fetch calendar.specialtyProfile
|
||||
join fetch calendar.studyForm
|
||||
where calendar.id = :id
|
||||
""")
|
||||
Optional<AcademicCalendar> findByIdWithDetailsForUpdate(@Param("id") Long id);
|
||||
}
|
||||
|
||||
@@ -31,4 +31,6 @@ public interface AcademicCalendarSubjectRepository extends JpaRepository<Academi
|
||||
List<AcademicCalendarSubject> findByCalendarIdInWithSubject(@Param("calendarIds") Collection<Long> calendarIds);
|
||||
|
||||
void deleteByAcademicCalendar_Id(Long calendarId);
|
||||
|
||||
boolean existsByAcademicCalendarIdAndSemesterNumberGreaterThan(Long calendarId, Integer semesterNumber);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,44 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AcademicYearRepository extends JpaRepository<AcademicYear, Long> {
|
||||
|
||||
Optional<AcademicYear> findByTitle(String title);
|
||||
|
||||
boolean existsByTitle(String title);
|
||||
|
||||
boolean existsByTitleAndIdNot(String title, Long id);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select year from AcademicYear year where year.id = :id")
|
||||
Optional<AcademicYear> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
@Query("""
|
||||
select case when count(year) > 0 then true else false end
|
||||
from AcademicYear year
|
||||
where year.startDate <= :endDate
|
||||
and year.endDate >= :startDate
|
||||
""")
|
||||
boolean existsOverlapping(@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
|
||||
@Query("""
|
||||
select case when count(year) > 0 then true else false end
|
||||
from AcademicYear year
|
||||
where year.id <> :excludedId
|
||||
and year.startDate <= :endDate
|
||||
and year.endDate >= :startDate
|
||||
""")
|
||||
boolean existsOverlappingExcluding(@Param("excludedId") Long excludedId,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.AuthLoginAttemptAudit;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public interface AuthLoginAttemptAuditRepository extends JpaRepository<AuthLoginAttemptAudit, Long> {
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(value = """
|
||||
WITH cleanup_candidates AS (
|
||||
SELECT id
|
||||
FROM auth_login_attempt_audit
|
||||
WHERE occurred_at < :cutoff
|
||||
ORDER BY occurred_at, id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT :batchSize
|
||||
)
|
||||
DELETE FROM auth_login_attempt_audit audit
|
||||
USING cleanup_candidates candidate
|
||||
WHERE audit.id = candidate.id
|
||||
""", nativeQuery = true)
|
||||
int deleteCleanupBatch(@Param("cutoff") Instant cutoff,
|
||||
@Param("batchSize") int batchSize);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.AuthLoginRateLimit;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AuthLoginRateLimitRepository extends JpaRepository<AuthLoginRateLimit, Long> {
|
||||
|
||||
@Modifying(flushAutomatically = true)
|
||||
@Query(value = """
|
||||
INSERT INTO auth_login_rate_limits (
|
||||
tenant, username_normalized, client_ip, failure_count,
|
||||
window_started_at, updated_at
|
||||
) VALUES (:tenant, :username, :clientIp, 0, :now, :now)
|
||||
ON CONFLICT (tenant, username_normalized, client_ip) DO NOTHING
|
||||
""", nativeQuery = true)
|
||||
int ensureExists(@Param("tenant") String tenant,
|
||||
@Param("username") String username,
|
||||
@Param("clientIp") String clientIp,
|
||||
@Param("now") Instant now);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
SELECT limit
|
||||
FROM AuthLoginRateLimit limit
|
||||
WHERE limit.tenant = :tenant
|
||||
AND limit.usernameNormalized = :username
|
||||
AND limit.clientIp = :clientIp
|
||||
""")
|
||||
Optional<AuthLoginRateLimit> findForUpdate(@Param("tenant") String tenant,
|
||||
@Param("username") String username,
|
||||
@Param("clientIp") String clientIp);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(value = """
|
||||
WITH cleanup_candidates AS (
|
||||
SELECT id
|
||||
FROM auth_login_rate_limits
|
||||
WHERE updated_at < :cutoff
|
||||
AND (blocked_until IS NULL OR blocked_until <= :now)
|
||||
ORDER BY updated_at, id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT :batchSize
|
||||
)
|
||||
DELETE FROM auth_login_rate_limits rate_limit
|
||||
USING cleanup_candidates candidate
|
||||
WHERE rate_limit.id = candidate.id
|
||||
""", nativeQuery = true)
|
||||
int deleteStaleBatch(@Param("cutoff") Instant cutoff,
|
||||
@Param("now") Instant now,
|
||||
@Param("batchSize") int batchSize);
|
||||
}
|
||||
@@ -1,11 +1,50 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.AuthRefreshToken;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AuthRefreshTokenRepository extends JpaRepository<AuthRefreshToken, Long> {
|
||||
|
||||
Optional<AuthRefreshToken> findByTokenHash(String tokenHash);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
SELECT token
|
||||
FROM AuthRefreshToken token
|
||||
JOIN FETCH token.user
|
||||
WHERE token.tokenHash = :tokenHash
|
||||
""")
|
||||
Optional<AuthRefreshToken> findByTokenHashForUpdate(@Param("tokenHash") String tokenHash);
|
||||
|
||||
/**
|
||||
* Удаляет ограниченную пачку старых audit-записей. SKIP LOCKED позволяет нескольким
|
||||
* backend-pod безопасно разбирать непересекающиеся пачки одной tenant-БД.
|
||||
*/
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(value = """
|
||||
WITH cleanup_candidates AS (
|
||||
SELECT id
|
||||
FROM auth_refresh_tokens
|
||||
WHERE expires_at < :cutoff
|
||||
OR (revoked_at IS NOT NULL AND revoked_at < :cutoff)
|
||||
ORDER BY LEAST(expires_at, COALESCE(revoked_at, expires_at)), id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT :batchSize
|
||||
)
|
||||
DELETE FROM auth_refresh_tokens token
|
||||
USING cleanup_candidates candidate
|
||||
WHERE token.id = candidate.id
|
||||
""", nativeQuery = true)
|
||||
int deleteCleanupBatch(@Param("cutoff") Instant cutoff,
|
||||
@Param("batchSize") int batchSize);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
|
||||
|
||||
@@ -14,4 +19,15 @@ public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
|
||||
List<StudentGroup> findByStatusNot(String status);
|
||||
|
||||
List<StudentGroup> findByDepartmentIdAndStatusNot(Long departmentId, String status);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
select studentGroup
|
||||
from StudentGroup studentGroup
|
||||
join fetch studentGroup.educationForm
|
||||
join fetch studentGroup.speciality
|
||||
join fetch studentGroup.specialtyProfile
|
||||
where studentGroup.id = :id
|
||||
""")
|
||||
Optional<StudentGroup> findByIdForUpdate(@Param("id") Long id);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.ScheduleOverride;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -15,10 +18,13 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
|
||||
select o
|
||||
from ScheduleOverride o
|
||||
left join fetch o.baseRuleSlot baseSlot
|
||||
left join fetch baseSlot.scheduleRule rule
|
||||
left join fetch rule.semester
|
||||
left join fetch o.newTimeSlot
|
||||
left join fetch o.newClassroom
|
||||
left join fetch o.newTeacher
|
||||
where o.lessonDate between :startDate and :endDate
|
||||
or o.targetLessonDate between :startDate and :endDate
|
||||
""")
|
||||
List<ScheduleOverride> findByLessonDateBetweenWithDetails(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@@ -26,4 +32,77 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
|
||||
);
|
||||
|
||||
Optional<ScheduleOverride> findByBaseRuleSlotIdAndLessonDate(Long baseRuleSlotId, LocalDate lessonDate);
|
||||
|
||||
@Query("""
|
||||
select distinct o
|
||||
from ScheduleOverride o
|
||||
join fetch o.baseRuleSlot baseSlot
|
||||
join fetch baseSlot.scheduleRule rule
|
||||
join fetch rule.subject
|
||||
left join fetch rule.groups
|
||||
join fetch rule.semester
|
||||
join fetch baseSlot.timeSlot
|
||||
join fetch baseSlot.teacher
|
||||
join fetch baseSlot.classroom
|
||||
join fetch baseSlot.lessonType
|
||||
left join fetch o.newTimeSlot
|
||||
left join fetch o.newClassroom
|
||||
left join fetch o.newTeacher
|
||||
order by o.lessonDate, o.id
|
||||
""")
|
||||
List<ScheduleOverride> findAllWithRegistryDetails();
|
||||
|
||||
@Query("""
|
||||
select distinct o
|
||||
from ScheduleOverride o
|
||||
join fetch o.baseRuleSlot baseSlot
|
||||
join fetch baseSlot.scheduleRule rule
|
||||
join fetch rule.subject
|
||||
left join fetch rule.groups
|
||||
join fetch rule.semester
|
||||
join fetch baseSlot.timeSlot
|
||||
join fetch baseSlot.teacher
|
||||
join fetch baseSlot.classroom
|
||||
join fetch baseSlot.lessonType
|
||||
left join fetch o.newTimeSlot
|
||||
left join fetch o.newClassroom
|
||||
left join fetch o.newTeacher
|
||||
where o.lessonDate between :startDate and :endDate
|
||||
or o.targetLessonDate between :startDate and :endDate
|
||||
order by o.lessonDate, o.id
|
||||
""")
|
||||
List<ScheduleOverride> findForRegistry(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select o
|
||||
from ScheduleOverride o
|
||||
left join fetch o.baseRuleSlot baseSlot
|
||||
left join fetch baseSlot.scheduleRule rule
|
||||
left join fetch rule.semester
|
||||
left join fetch o.newTimeSlot
|
||||
left join fetch o.newClassroom
|
||||
left join fetch o.newTeacher
|
||||
where o.lessonDate in :dates
|
||||
or o.targetLessonDate in :dates
|
||||
""")
|
||||
List<ScheduleOverride> findAffectingDatesWithDetails(@Param("dates") Collection<LocalDate> dates);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select o from ScheduleOverride o where o.id = :id")
|
||||
Optional<ScheduleOverride> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
select o
|
||||
from ScheduleOverride o
|
||||
where o.baseRuleSlot.id = :baseRuleSlotId
|
||||
and o.lessonDate = :lessonDate
|
||||
""")
|
||||
Optional<ScheduleOverride> findByBaseRuleSlotIdAndLessonDateForUpdate(
|
||||
@Param("baseRuleSlotId") Long baseRuleSlotId,
|
||||
@Param("lessonDate") LocalDate lessonDate
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.ScheduleRule;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Collection;
|
||||
|
||||
public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> {
|
||||
|
||||
@@ -57,6 +61,54 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
@Param("semesterId") Long semesterId
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
join r.groups matchingGroup
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
left join fetch r.groups groups
|
||||
left join fetch r.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
left join fetch slots.subgroups slotSubgroups
|
||||
left join fetch slotSubgroups.studentGroup
|
||||
left join fetch slots.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
where matchingGroup.id in :groupIds
|
||||
and sem.id in :semesterIds
|
||||
and r.status <> 'ARCHIVED'
|
||||
""")
|
||||
List<ScheduleRule> findByGroupIdsAndSemesterIds(
|
||||
@Param("groupIds") Collection<Long> groupIds,
|
||||
@Param("semesterIds") Collection<Long> semesterIds
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
join r.slots teacherSlot
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
left join fetch r.groups groups
|
||||
left join fetch r.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
left join fetch slots.subgroups slotSubgroups
|
||||
left join fetch slotSubgroups.studentGroup
|
||||
left join fetch slots.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
where teacherSlot.teacher.id = :teacherId
|
||||
and sem.id in :semesterIds
|
||||
and r.status <> 'ARCHIVED'
|
||||
""")
|
||||
List<ScheduleRule> findByTeacherIdAndSemesterIds(
|
||||
@Param("teacherId") Long teacherId,
|
||||
@Param("semesterIds") Collection<Long> semesterIds
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
@@ -76,6 +128,34 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
""")
|
||||
List<ScheduleRule> findAllWithDetails();
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select rule from ScheduleRule rule where rule.id = :id")
|
||||
Optional<ScheduleRule> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
@Query("""
|
||||
select distinct rule
|
||||
from ScheduleRule rule
|
||||
left join fetch rule.subject
|
||||
left join fetch rule.semester semester
|
||||
left join fetch semester.academicYear
|
||||
left join fetch rule.groups groups
|
||||
left join fetch rule.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
left join fetch slots.subgroups slotSubgroups
|
||||
left join fetch slotSubgroups.studentGroup
|
||||
left join fetch slots.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
where semester.id = :semesterId
|
||||
and rule.status <> 'ARCHIVED'
|
||||
and (:excludeRuleId is null or rule.id <> :excludeRuleId)
|
||||
order by rule.id
|
||||
""")
|
||||
List<ScheduleRule> findActiveBySemesterIdWithDetails(
|
||||
@Param("semesterId") Long semesterId,
|
||||
@Param("excludeRuleId") Long excludeRuleId
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
@@ -92,5 +172,5 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
left join fetch slots.lessonType
|
||||
where r.id = :id
|
||||
""")
|
||||
java.util.Optional<ScheduleRule> findByIdWithDetails(@Param("id") Long id);
|
||||
Optional<ScheduleRule> findByIdWithDetails(@Param("id") Long id);
|
||||
}
|
||||
|
||||
@@ -9,4 +9,6 @@ public interface ScheduleRuleSlotRepository extends JpaRepository<ScheduleRuleSl
|
||||
|
||||
@Query("select case when count(slot) > 0 then true else false end from ScheduleRuleSlot slot join slot.subgroups subgroup where subgroup.id = :subgroupId")
|
||||
boolean existsBySubgroupId(@Param("subgroupId") Long subgroupId);
|
||||
|
||||
boolean existsByTimeSlotId(Long timeSlotId);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user