sciagent code + Gitea Actions CI/CD
CI/CD / backend (push) Failing after 2m8s
CI/CD / frontend (push) Failing after 1m40s
CI/CD / deploy (push) Has been skipped

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thinh Lam
2026-06-30 09:38:30 +07:00
commit 688fac73e9
1167 changed files with 158244 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Wrapper: apply migration 007 on an existing Postgres volume. See be0 script for options.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
exec "$ROOT/be0/scripts/apply-migration-007.sh" "$@"
+16
View File
@@ -0,0 +1,16 @@
@echo off
REM Wrapper cho build-all.ps1 — ưu tiên pwsh (PS7+), fallback powershell (PS5.1)
setlocal
set ROOT=%~dp0..
pushd "%ROOT%"
where pwsh >nul 2>&1
if %ERRORLEVEL% == 0 (
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0build-all.ps1" %*
) else (
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build-all.ps1" %*
)
set RC=%ERRORLEVEL%
popd
endlocal & exit /b %RC%
+129
View File
@@ -0,0 +1,129 @@
# Build all components: .NET backend + 2 React frontends
# Usage:
# .\scripts\build-all.ps1 # Build all
# .\scripts\build-all.ps1 -Clean # Clean + Build
# .\scripts\build-all.ps1 -Backend # Backend only
# .\scripts\build-all.ps1 -Frontend # Frontends only
# .\scripts\build-all.ps1 -Restore # Just restore deps
[CmdletBinding()]
param(
[switch]$Clean,
[switch]$Backend,
[switch]$Frontend,
[switch]$Restore,
[switch]$NoNpmInstall,
[string]$Configuration = 'Release'
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
Set-Location $root
$All = -not ($Backend -or $Frontend -or $Restore)
function Write-Step {
param([string]$Message)
Write-Host ""
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host " $Message" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
}
function Test-CommandExists {
param([string]$Command)
return ($null -ne (Get-Command $Command -ErrorAction SilentlyContinue))
}
function Invoke-Step {
param([string]$Description, [scriptblock]$Block)
Write-Host "-> $Description" -ForegroundColor Yellow
& $Block
if ($LASTEXITCODE -ne 0) {
Write-Host "[FAIL] $Description" -ForegroundColor Red
exit $LASTEXITCODE
}
}
# Pre-flight checks
Write-Step "Pre-flight checks"
if (-not (Test-CommandExists 'dotnet')) {
Write-Host "[ERROR] dotnet not found" -ForegroundColor Red
exit 1
}
$dotnetVersion = (dotnet --version)
Write-Host "[OK] .NET SDK: $dotnetVersion" -ForegroundColor Green
if (Test-CommandExists 'node') {
Write-Host "[OK] Node.js: $(node --version)" -ForegroundColor Green
}
if (Test-CommandExists 'npm') {
Write-Host "[OK] npm: $(npm --version)" -ForegroundColor Green
}
# Backend
if ($All -or $Backend -or $Restore -or $Clean) {
if ($Clean) {
Write-Step "Clean .NET solution"
Invoke-Step "dotnet clean" { dotnet clean DH_Y_DUOC.slnx -c $Configuration -v minimal }
}
Write-Step ".NET: Restore packages"
Invoke-Step "dotnet restore" { dotnet restore DH_Y_DUOC.slnx }
if (-not $Restore) {
Write-Step ".NET: Build solution ($Configuration)"
Invoke-Step "dotnet build" { dotnet build DH_Y_DUOC.slnx -c $Configuration --no-restore -v minimal }
}
}
# Frontends
if ($All -or $Frontend) {
if (-not (Test-CommandExists 'npm')) {
Write-Host "[WARN] Skip frontends (npm not installed)" -ForegroundColor Yellow
}
else {
$frontends = @('fe0', 'fe-admin')
foreach ($fe in $frontends) {
$feDir = Join-Path $root $fe
if (-not (Test-Path $feDir)) {
Write-Host "[SKIP] $fe directory not found" -ForegroundColor Yellow
continue
}
Write-Step "Frontend $fe : Build"
Push-Location $feDir
try {
$needInstall = (-not $NoNpmInstall) -and ((-not (Test-Path 'node_modules')) -or $Clean)
if ($needInstall) {
if ($Clean -and (Test-Path 'node_modules')) {
Write-Host "-> Clean: remove node_modules" -ForegroundColor Yellow
Remove-Item -Recurse -Force node_modules
}
Invoke-Step "npm install" { npm install }
}
else {
Write-Host "[OK] node_modules exists (skip npm install)" -ForegroundColor Green
}
if (-not $Restore) {
Invoke-Step "npm run build" { npm run build }
}
}
finally {
Pop-Location
}
}
}
}
# Summary
Write-Step "BUILD COMPLETED"
Write-Host " - .NET output: src/Backend/DYD.Api/bin/$Configuration/net10.0/"
Write-Host " - fe0 output: fe0/dist/"
Write-Host " - fe-admin output: fe-admin/dist/"
Write-Host ""
Write-Host "To run dev:" -ForegroundColor Cyan
Write-Host " Backend: dotnet run --project src/Backend/DYD.Api (or F5 in VS)"
Write-Host " Frontends: .\scripts\start-frontends.cmd"
Write-Host " AI: docker compose up -d qdrant aiservice"
Write-Host ""
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""
Build Word template from original form file by replacing dots-lines ("..........")
với placeholders {{xxx}} theo vị trí label trong document.
Usage:
python scripts/build-word-template.py
Input: src/Backend/DYD.Api/Templates/bao-cao-template-original.docx
Output: src/Backend/DYD.Api/Templates/bao-cao-template.docx
"""
import re
import shutil
import sys
import tempfile
import xml.etree.ElementTree as ET
import zipfile
from pathlib import Path
NS_URI = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
W = f'{{{NS_URI}}}'
ET.register_namespace('w', NS_URI)
# Paragraph index → placeholder token (after dot-line replacement).
# Determined by running script once and printing all paragraphs, then mapping manually.
# Key = paragraph index (in document iteration order)
# Value = placeholder name ({{VALUE}} will be inserted if current paragraph is dots-line
# OR appended to preceding label if current paragraph IS the label itself)
# For cover page (no dots after labels), we append placeholder AT END of label paragraph
# Format: label_text_regex → placeholder (inject at end of paragraph)
COVER_LABEL_APPEND = [
(re.compile(r'^Tên sáng kiến \(Tiếng Việt\):\s*$'), 'coverInitiativeName'),
(re.compile(r'^Tác giả/nhóm tác giả sáng kiến:\s*$'), 'coverAuthors'),
(re.compile(r'^Đơn vị công tác:\s*$'), 'coverUnit'),
(re.compile(r'^Thông tin liên hệ \(Điện thoại, Email\):\s*$'), 'coverContact'),
(re.compile(r'^NĂM 20\.\.\.$'), 'coverYear'),
]
# Map: label regex (previous paragraph) → placeholder for the dots-paragraph following
LABEL_TO_PLACEHOLDER = [
# Mẫu 01
(re.compile(r'^1\.\s*Mở đầu'), 'introduction'),
(re.compile(r'^2\.\s*Tên sáng kiến\b'), 'initiativeName'),
(re.compile(r'^3\.\s*Lĩnh vực áp dụng'), 'applicationField'),
(re.compile(r'^4\.1\s*Tình trạng giải pháp'), 'currentStatus'),
(re.compile(r'^-\s*Mục đích của sáng kiến'), 'purpose'),
(re.compile(r'^\+\s*Các bước thực hiện'), 'implementationSteps'),
(re.compile(r'^\+\s*Các điều kiện cần thiết'), 'conditions'),
(re.compile(r'^-\s*Về tính mới'), 'novelty'),
# Effectiveness 10 items
(re.compile(r'^\+\s*Tạo ra lợi ích kinh tế'), 'effEconomic'),
(re.compile(r'^\+\s*Đem lại hiệu quả trong giảng dạy'), 'effTeaching'),
(re.compile(r'^\+\s*Tăng năng suất lao động'), 'effProductivity'),
(re.compile(r'^\+\s*Nâng cao hiệu quả công việc'), 'effSocial'),
(re.compile(r'^\+\s*Nâng cao chất lượng công việc'), 'effQuality'),
(re.compile(r'^\+\s*Giảm chi phí'), 'effCost'),
(re.compile(r'^\+\s*Cải thiện môi trường'), 'effEnvironment'),
(re.compile(r'^\+\s*Bảo vệ sức khỏe'), 'effHealth'),
(re.compile(r'^\+\s*Đảm bảo an toàn lao động'), 'effLaborSafety'),
(re.compile(r'^\+\s*Nâng cao khả năng, trình độ'), 'effAwareness'),
(re.compile(r'^6\.\s*Những thông tin cần được bảo mật'), 'confidentialInfo'),
# Mẫu 02
(re.compile(r'^-\s*Chủ đầu tư tạo ra sáng kiến'), 'investorName'),
(re.compile(r'^-\s*Lĩnh vực áp dụng sáng kiến'), 'applicationField02'),
(re.compile(r'^-\s*Ngày sáng kiến được áp dụng'), 'firstApplyDate'),
(re.compile(r'^-\s*Nội dung của sáng kiến'), 'contentSummary'),
(re.compile(r'^Những thông tin cần được bảo mật'), 'confidentialInfo02'),
(re.compile(r'^Các điều kiện cần thiết để áp dụng'), 'conditions02'),
(re.compile(r'^Đánh giá lợi ích thu được hoặc dự kiến có thể thu được do áp dụng sáng kiến theo ý kiến của tác giả'), 'authorEvaluation'),
(re.compile(r'^Đánh giá lợi ích thu được hoặc dự kiến có thể thu được do áp dụng sáng kiến theo ý kiến của tổ chức'), 'trialEvaluation'),
# Mẫu 03
(re.compile(r'^1\.\s*Tên sáng kiến'), 'initiativeName03'),
(re.compile(r'^2\.\s*Tác giả chính'), 'mainAuthor03'),
(re.compile(r'^Chức vụ, đơn vị công tác'), 'position03'),
# Mẫu 04
(re.compile(r'^Kết luận'), 'conclusion'),
]
# Dots-line pattern — paragraph text (stripped, whitespace collapsed) is 50+ dots
DOTS_PATTERN = re.compile(r'^[\s\.]{50,}$')
def para_text(p):
"""Concat all w:t text of paragraph p."""
return ''.join((t.text or '') for t in p.iter(f'{W}t')).strip()
def set_para_text(p, new_text):
"""Replace paragraph's run text with single new_text. Keeps first run's properties."""
# Find all runs
runs = list(p.findall(f'{W}r'))
if not runs:
# No run — add one with text
r = ET.SubElement(p, f'{W}r')
t = ET.SubElement(r, f'{W}t')
t.text = new_text
t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
return
# Remove all runs except first
for r in runs[1:]:
p.remove(r)
# Clear all <w:t> in first run, leave <w:rPr> intact
first = runs[0]
for t in list(first.findall(f'{W}t')):
first.remove(t)
# Remove all non-rPr, non-text children? Leave them alone (breaks etc).
# Add new text element
t = ET.SubElement(first, f'{W}t')
t.text = new_text
t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
def append_placeholder_to_para(p, placeholder):
"""Append text ' {{placeholder}}' to end of paragraph (new run)."""
r = ET.SubElement(p, f'{W}r')
t = ET.SubElement(r, f'{W}t')
t.text = f' {{{{{placeholder}}}}}'
t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
def find_placeholder_for_label(text):
for regex, placeholder in LABEL_TO_PLACEHOLDER:
if regex.match(text):
return placeholder
return None
def find_cover_label(text):
for regex, placeholder in COVER_LABEL_APPEND:
if regex.match(text):
return placeholder
return None
def process(xml_path):
tree = ET.parse(xml_path)
root = tree.getroot()
paragraphs = list(root.iter(f'{W}p'))
prev_label_placeholder = None
used_placeholders = set()
cover_done = set() # only replace cover labels once (file has 2 cover pages)
dots_counter = 0
for i, p in enumerate(paragraphs):
text = para_text(p)
if not text:
continue
# 1. Cover label — append placeholder
cover_pl = find_cover_label(text)
if cover_pl and cover_pl not in cover_done:
append_placeholder_to_para(p, cover_pl)
cover_done.add(cover_pl)
prev_label_placeholder = None
continue
# 2. Dots line → replace with placeholder from prev label
if DOTS_PATTERN.match(text):
dots_counter += 1
if prev_label_placeholder and prev_label_placeholder not in used_placeholders:
set_para_text(p, f'{{{{{prev_label_placeholder}}}}}')
used_placeholders.add(prev_label_placeholder)
prev_label_placeholder = None
else:
# extra dots line without matching label — tag with generic counter
set_para_text(p, f'{{{{extra_{dots_counter}}}}}')
continue
# 3. Label paragraph → remember placeholder for NEXT dots line
label_pl = find_placeholder_for_label(text)
if label_pl:
prev_label_placeholder = label_pl
continue
# 4. Other paragraph — reset label if it wasn't matched
# Don't reset prev_label if current para is just description (italic note etc.)
# Keep prev_label until we see dots or a new label
tree.write(xml_path, encoding='UTF-8', xml_declaration=True)
return used_placeholders, dots_counter
def main():
repo_root = Path(__file__).parent.parent
src = repo_root / 'src/Backend/DYD.Api/Templates/bao-cao-template-original.docx'
dst = repo_root / 'src/Backend/DYD.Api/Templates/bao-cao-template.docx'
if not src.exists():
print(f'ERROR: source template not found at {src}')
sys.exit(1)
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir)
# Unzip
with zipfile.ZipFile(src, 'r') as z:
z.extractall(tmp)
doc_xml = tmp / 'word' / 'document.xml'
used, dots = process(doc_xml)
print(f'Replaced {len(used)} placeholders from {dots} dots-lines.')
print(f'Placeholders: {sorted(used)}')
# Rezip
if dst.exists():
dst.unlink()
with zipfile.ZipFile(dst, 'w', zipfile.ZIP_DEFLATED) as zout:
for path in tmp.rglob('*'):
if path.is_file():
zout.write(path, path.relative_to(tmp))
print(f'Wrote {dst}')
if __name__ == '__main__':
main()
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Run on the server from repo root with the stack up:
# bash scripts/check-prod-stack.sh
# Verifies Postgres, MinIO, and be0 /health; be0 → postgres:5432 and minio:9000 reachability.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE=(docker compose --env-file "${ROOT}/.env" -f "${ROOT}/docker-compose.prod.yml")
cd "$ROOT"
if [[ ! -f "${ROOT}/.env" ]]; then
printf 'Missing %s\n' "${ROOT}/.env" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "${ROOT}/.env"
set +a
echo "=== Compose ps ==="
"${COMPOSE[@]}" ps
echo ""
echo "=== Postgres (pg_isready inside postgres container) ==="
"${COMPOSE[@]}" exec -T postgres pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}"
echo ""
echo "=== MinIO health (inside minio container) ==="
"${COMPOSE[@]}" exec -T minio curl -sf "http://127.0.0.1:9000/minio/health/live" >/dev/null
echo "OK"
echo ""
echo "=== be0 /health ==="
"${COMPOSE[@]}" exec -T be0 curl -sf "http://127.0.0.1:4402/health" >/dev/null
echo "OK"
echo ""
echo "=== be0 → postgres:5432 (TCP) ==="
"${COMPOSE[@]}" exec -T be0 python -c "import socket; s=socket.create_connection(('postgres',5432),3); s.close()"
echo "OK"
echo ""
echo "=== be0 → minio:9000 (HTTP) ==="
"${COMPOSE[@]}" exec -T be0 curl -sf "http://minio:9000/minio/health/live" >/dev/null
echo "OK"
echo ""
echo "=== fe0 → be0 proxy env ==="
"${COMPOSE[@]}" exec -T fe0 /bin/sh -c 'echo "VITE_DEV_PROXY_TARGET=${VITE_DEV_PROXY_TARGET:-<unset>}"'
echo ""
printf 'Done. If be0 /health failed, read logs: docker compose --env-file .env -f docker-compose.prod.yml logs be0\n'
printf 'Typical cloud issue: POSTGRES_PASSWORD in .env does not match the first-init Postgres volume — see docs/deploy-production-docker.md\n'
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Deploy the Docker Compose production stack from the repo root.
# Prerequisites: `.env` (from `.env.example`) on the SAME host/path as this repo.
#
# Usage:
# ./scripts/deploy-prod.sh # pull pinned images, build, up -d
# ./scripts/deploy-prod.sh --no-pull # offline / cached images only
#fdf
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_ABS="${ROOT}/docker-compose.prod.yml"
cd "$ROOT"
DO_PULL=1
while [[ $# -gt 0 ]]; do
case "$1" in
--no-pull) DO_PULL=0 ;;
-h|--help)
grep -E '^#( |$)' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
exit 1
;;
esac
shift
done
if [[ ! -f "$ROOT/.env" ]]; then
printf 'Missing %s\nRun: cp .env.example .env && edit secrets\n' "$ROOT/.env" >&2
exit 1
fi
echo "→ Validating prod environment..."
"$ROOT/scripts/verify-prod-env.sh"
echo "→ Validating compose file..."
docker compose --env-file "$ROOT/.env" -f "$COMPOSE_ABS" config >/dev/null
if [[ "$DO_PULL" -eq 1 ]]; then
echo "→ Pulling images..."
docker compose --env-file "$ROOT/.env" -f "$COMPOSE_ABS" pull
fi
echo "→ Building images..."
docker compose --env-file "$ROOT/.env" -f "$COMPOSE_ABS" build
echo "→ Starting stack..."
docker compose --env-file "$ROOT/.env" -f "$COMPOSE_ABS" up -d --remove-orphans
echo "→ Status"
docker compose --env-file "$ROOT/.env" -f "$COMPOSE_ABS" ps
printf '\nDeployed. Logs: docker compose --env-file .env -f docker-compose.prod.yml logs -f be0\n'
printf 'Connectivity: bash scripts/check-prod-stack.sh\n'
printf 'Postgres auth is FIXED on first DB init — docs/deploy-production-docker.md\n'
+53
View File
@@ -0,0 +1,53 @@
# ============================================================================
# DYD — Deployment environment variables (TEMPLATE)
#
# Copy file này thành .env.deploy.local (đã trong .gitignore) và điền secrets.
# KHÔNG commit file .env.deploy.local vào git.
# ============================================================================
# --- VPS ---------------------------------------------------------------------
VPS_IP=103.124.94.58
VPS_USER=Administrator
# VPS_PASSWORD=... # Không khuyến khích lưu file — nhập khi RDP
# --- DOMAINS -----------------------------------------------------------------
API_DOMAIN=api.ski-ump.com.vn
USER_DOMAIN=ski-ump.com.vn
ADMIN_DOMAIN=admin.ski-ump.com.vn
# --- SQL SERVER --------------------------------------------------------------
SQL_SERVER=103.124.94.58,1433
SQL_DATABASE=DYD_Prod
# Login admin (chỉ để SETUP lần đầu — KHÔNG dùng cho app)
# SQL_SA_USER=sa
# SQL_SA_PASSWORD=...
# Login app (tạo bằng scripts/deployment/sql/01-create-database.sql)
SQL_APP_USER=dyd_app
# SQL_APP_PASSWORD=... # Generate: [System.Web.Security.Membership]::GeneratePassword(32,8)
# Connection string cho .NET (KHÔNG commit)
# DB_CONNECTION_STRING="Server=103.124.94.58,1433;Database=DYD_Prod;User Id=dyd_app;Password=XXX;TrustServerCertificate=True;MultipleActiveResultSets=True;"
# --- JWT ---------------------------------------------------------------------
# Generate: [Convert]::ToBase64String([byte[]]::new(48)) sau [System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
# Hoặc online: https://generate-random.org/api-token-generator (64 char)
# JWT_SIGNING_KEY=...
# --- AI SERVICE --------------------------------------------------------------
AI_SERVICE_URL=http://localhost:4402
# AI_SERVICE_API_KEY=... # Random hex 32: [BitConverter]::ToString((New-Object byte[] 32).Tap({[Security.Cryptography.RandomNumberGenerator]::Fill($_)})) -replace '-'
# --- GITEA -------------------------------------------------------------------
GITEA_URL=http://103.124.94.58:3000
# GITEA_ADMIN_USER=admin
# GITEA_ADMIN_PASSWORD=... # Set qua web installer
# GITEA_RUNNER_TOKEN=... # Lấy ở Site Admin → Actions → Runner Management
# --- EMAIL (for win-acme Let's Encrypt + SMTP future) -----------------------
ADMIN_EMAIL=admin@ski-ump.com.vn
# SMTP_HOST=smtp.sendgrid.net
# SMTP_PORT=587
# SMTP_USER=apikey
# SMTP_PASSWORD=...
@@ -0,0 +1,25 @@
# ============================================================================
# COPY TOÀN BỘ block PowerShell BÊN DƯỚI, paste vào PowerShell Admin trên VPS
# (Nhỏ gọn ~4KB, clipboard RDP paste tốt)
# ============================================================================
$ErrorActionPreference='Stop'; Write-Host "[DYD SSH] Start..." -F Cyan
$cap = Get-WindowsCapability -Online | ? Name -like 'OpenSSH.Server*'
if ($cap.State -ne 'Installed') { Add-WindowsCapability -Online -Name $cap.Name | Out-Null; Write-Host "[OK] OpenSSH installed" -F Green } else { Write-Host "[OK] OpenSSH already installed" -F Green }
Start-Service sshd; Set-Service sshd -StartupType Automatic; Set-Service ssh-agent -StartupType Automatic; Start-Service ssh-agent -ErrorAction SilentlyContinue
if (-not (Get-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -ErrorAction SilentlyContinue)) { New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -DisplayName 'OpenSSH SSH Server' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 | Out-Null }
New-ItemProperty -Path 'HKLM:\SOFTWARE\OpenSSH' -Name DefaultShell -Value 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' -PropertyType String -Force | Out-Null
$pub = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM/SmlEVa41JmeIAwQOtEkdzUo1BLPJbJ+oDqDYm1ywQ dyd-vps-deploy-20260415'
$authFile = 'C:\ProgramData\ssh\administrators_authorized_keys'
if (-not (Test-Path (Split-Path $authFile))) { New-Item -ItemType Directory -Path (Split-Path $authFile) -Force | Out-Null }
$existing = if (Test-Path $authFile) { Get-Content $authFile -Raw } else { '' }
if ($existing -notmatch [regex]::Escape($pub)) { Add-Content -Path $authFile -Value $pub -Encoding UTF8 }
icacls $authFile /inheritance:r | Out-Null; icacls $authFile /grant 'Administrators:F' /grant 'SYSTEM:F' | Out-Null
Restart-Service sshd
$sshd = Get-Service sshd; $listen = Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue
Write-Host ""; Write-Host "=====================================" -F Green; Write-Host " DONE — SSH server ready" -F Green; Write-Host "=====================================" -F Green
Write-Host " sshd status : $($sshd.Status)" -F Green
Write-Host " port 22 : $(if($listen){'LISTENING'}else{'NOT listening'})" -F $(if($listen){'Green'}else{'Red'})
Write-Host " public key : added to $authFile" -F Green
Write-Host ""; Write-Host "Dev can test:" -F Yellow
Write-Host " ssh -i ~/.ssh/dyd_vps Administrator@103.124.94.58 hostname" -F Yellow
+143
View File
@@ -0,0 +1,143 @@
# ============================================================================
# 00 — Enable OpenSSH Server trên Windows Server
# USAGE:
# 1. RDP vào VPS (103.124.94.58:3389)
# 2. Mở PowerShell AS ADMINISTRATOR
# 3. Copy-paste TOÀN BỘ file này vào PowerShell rồi Enter
# 4. Chờ ~1 phút, script sẽ print "DONE" khi xong
# 5. Báo lại cho dev để test SSH
#
# Script idempotent — chạy nhiều lần OK.
# ============================================================================
#Requires -RunAsAdministrator
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
Write-Host ""
Write-Host "=========================================================" -ForegroundColor Cyan
Write-Host " DYD — Enable OpenSSH Server on Windows" -ForegroundColor Cyan
Write-Host "=========================================================" -ForegroundColor Cyan
# --- 1. Install OpenSSH Server capability ---
Write-Host ""
Write-Host "[1/6] Install OpenSSH.Server capability ..." -ForegroundColor Yellow
$cap = Get-WindowsCapability -Online | Where-Object Name -like 'OpenSSH.Server*'
if ($cap.State -ne 'Installed') {
Add-WindowsCapability -Online -Name $cap.Name | Out-Null
Write-Host " [OK] Installed" -ForegroundColor Green
}
else {
Write-Host " [OK] Already installed" -ForegroundColor Green
}
# --- 2. Start sshd + auto-start ---
Write-Host ""
Write-Host "[2/6] Start sshd service ..." -ForegroundColor Yellow
Start-Service sshd
Set-Service -Name sshd -StartupType Automatic
# Start ssh-agent too (tùy, cho key management)
Set-Service -Name ssh-agent -StartupType Automatic
Start-Service ssh-agent -ErrorAction SilentlyContinue
Write-Host " [OK] sshd running, auto-start enabled" -ForegroundColor Green
# --- 3. Firewall rule port 22 ---
Write-Host ""
Write-Host "[3/6] Firewall rule port 22 ..." -ForegroundColor Yellow
$rule = Get-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -ErrorAction SilentlyContinue
if (-not $rule) {
New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' `
-DisplayName 'OpenSSH SSH Server (sshd)' `
-Enabled True -Direction Inbound -Protocol TCP -Action Allow `
-LocalPort 22 | Out-Null
Write-Host " [OK] Firewall rule created" -ForegroundColor Green
}
else {
Enable-NetFirewallRule -Name 'OpenSSH-Server-In-TCP'
Write-Host " [OK] Firewall rule enabled" -ForegroundColor Green
}
# --- 4. Set DefaultShell = PowerShell (thay cho cmd) ---
Write-Host ""
Write-Host "[4/6] Set DefaultShell = PowerShell ..." -ForegroundColor Yellow
New-ItemProperty -Path 'HKLM:\SOFTWARE\OpenSSH' `
-Name DefaultShell `
-Value 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' `
-PropertyType String -Force | Out-Null
Write-Host " [OK] DefaultShell set to PowerShell" -ForegroundColor Green
# --- 5. Add dev machine public key to authorized_keys ---
Write-Host ""
Write-Host "[5/6] Add dev public key ..." -ForegroundColor Yellow
# === PUBLIC KEY ĐÃ EMBED — KHÔNG commit private key ===
$PublicKey = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM/SmlEVa41JmeIAwQOtEkdzUo1BLPJbJ+oDqDYm1ywQ dyd-vps-deploy-20260415'
# For Administrator account, dùng C:\ProgramData\ssh\administrators_authorized_keys
# (KHÔNG dùng ~/.ssh/authorized_keys)
$authFile = 'C:\ProgramData\ssh\administrators_authorized_keys'
# Ensure directory exists
$authDir = Split-Path $authFile
if (-not (Test-Path $authDir)) {
New-Item -ItemType Directory -Path $authDir -Force | Out-Null
}
# Append key nếu chưa có (idempotent)
$existing = if (Test-Path $authFile) { Get-Content $authFile -Raw } else { '' }
if ($existing -notmatch [regex]::Escape($PublicKey)) {
Add-Content -Path $authFile -Value $PublicKey -Encoding UTF8
Write-Host " [OK] Public key added" -ForegroundColor Green
}
else {
Write-Host " [OK] Public key already present" -ForegroundColor Green
}
# Fix permission — QUAN TRỌNG, sai permission = SSH silently reject key
# Chỉ Administrators + SYSTEM được đọc
icacls $authFile /inheritance:r | Out-Null
icacls $authFile /grant 'Administrators:F' /grant 'SYSTEM:F' | Out-Null
Write-Host " [OK] Permission locked (Admin + SYSTEM only)" -ForegroundColor Green
# --- 6. Verify ---
Write-Host ""
Write-Host "[6/6] Verify ..." -ForegroundColor Yellow
$sshd = Get-Service sshd
if ($sshd.Status -eq 'Running') {
Write-Host " [OK] sshd: Running" -ForegroundColor Green
}
else {
Write-Host " [FAIL] sshd: $($sshd.Status)" -ForegroundColor Red
}
# Test listener
$listening = Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue
if ($listening) {
Write-Host " [OK] Port 22: listening on $($listening[0].LocalAddress)" -ForegroundColor Green
}
else {
Write-Host " [WARN] Port 22: not listening (may need restart)" -ForegroundColor Yellow
}
# Test firewall
$fw = Get-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -ErrorAction SilentlyContinue
if ($fw -and $fw.Enabled -eq 'True') {
Write-Host " [OK] Firewall: allowed" -ForegroundColor Green
}
# --- DONE ---
Write-Host ""
Write-Host "=========================================================" -ForegroundColor Green
Write-Host " DONE — SSH server ready" -ForegroundColor Green
Write-Host "=========================================================" -ForegroundColor Green
Write-Host ""
Write-Host "Dev machine can now connect:"
Write-Host " ssh -i ~/.ssh/dyd_vps Administrator@103.124.94.58" -ForegroundColor Yellow
Write-Host ""
Write-Host "Test từ máy dev:"
Write-Host " ssh -i ~/.ssh/dyd_vps Administrator@103.124.94.58 hostname" -ForegroundColor Yellow
Write-Host ""
Write-Host "[!] Nếu đổi ý muốn disable SSH sau, chạy:"
Write-Host " Stop-Service sshd; Set-Service sshd -StartupType Disabled"
@@ -0,0 +1,167 @@
# ============================================================================
# 01 — Install prerequisites on Windows Server (Run as Administrator)
# Target: Windows Server 2019/2022 with IIS
# Usage: Run via RDP on VPS (103.124.94.58)
# ============================================================================
#Requires -RunAsAdministrator
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
function Write-Step($msg) {
Write-Host ""
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host " $msg" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
}
function Test-CommandExists($cmd) {
$null -ne (Get-Command $cmd -ErrorAction SilentlyContinue)
}
# ---------------------------------------------------------------------------
Write-Step "1/7 — Enable IIS features"
# ---------------------------------------------------------------------------
$features = @(
'IIS-WebServerRole', 'IIS-WebServer', 'IIS-CommonHttpFeatures',
'IIS-HttpErrors', 'IIS-HttpRedirect', 'IIS-StaticContent',
'IIS-DefaultDocument', 'IIS-DirectoryBrowsing',
'IIS-ApplicationDevelopment', 'IIS-ISAPIExtensions', 'IIS-ISAPIFilter',
'IIS-Security', 'IIS-RequestFiltering', 'IIS-BasicAuthentication',
'IIS-WindowsAuthentication', 'IIS-HealthAndDiagnostics', 'IIS-HttpLogging',
'IIS-LoggingLibraries', 'IIS-Performance', 'IIS-HttpCompressionStatic',
'IIS-HttpCompressionDynamic', 'IIS-WebServerManagementTools',
'IIS-ManagementConsole', 'IIS-ManagementScriptingTools',
'IIS-WebSockets', 'NetFx4Extended-ASPNET45',
'IIS-ASPNET45', 'IIS-NetFxExtensibility45'
)
foreach ($feature in $features) {
$state = (Get-WindowsOptionalFeature -Online -FeatureName $feature -ErrorAction SilentlyContinue).State
if ($state -ne 'Enabled') {
Write-Host " Enabling $feature ..." -ForegroundColor Yellow
Enable-WindowsOptionalFeature -Online -FeatureName $feature -NoRestart -ErrorAction SilentlyContinue | Out-Null
}
else {
Write-Host " [OK] $feature" -ForegroundColor Green
}
}
# ---------------------------------------------------------------------------
Write-Step "2/7 — Install ASP.NET Core 10 Hosting Bundle"
# ---------------------------------------------------------------------------
if (Test-Path 'HKLM:\SOFTWARE\Microsoft\ASP.NET Core\Hosting Bundle\v10.0') {
Write-Host " [OK] ASP.NET Core 10 Hosting Bundle already installed" -ForegroundColor Green
}
else {
$url = 'https://builds.dotnet.microsoft.com/dotnet/Runtime/10.0.6/dotnet-hosting-10.0.6-win.exe'
$exe = "$env:TEMP\dotnet-hosting-10.0.6-win.exe"
Write-Host " Downloading $url ..." -ForegroundColor Yellow
Invoke-WebRequest -Uri $url -OutFile $exe
Write-Host " Installing (silent)..." -ForegroundColor Yellow
Start-Process -FilePath $exe -ArgumentList '/quiet','/install','/norestart' -Wait
Remove-Item $exe -Force
Write-Host " [OK] ASP.NET Core 10 Hosting Bundle installed" -ForegroundColor Green
}
# ---------------------------------------------------------------------------
Write-Step "3/7 — Install IIS URL Rewrite 2.1"
# ---------------------------------------------------------------------------
if (Test-Path 'HKLM:\SOFTWARE\Microsoft\IIS Extensions\URL Rewrite') {
Write-Host " [OK] URL Rewrite already installed" -ForegroundColor Green
}
else {
$url = 'https://download.microsoft.com/download/1/2/8/128E2E22-C1B9-44A4-BE2A-5859ED1D4592/rewrite_amd64_en-US.msi'
$msi = "$env:TEMP\rewrite_amd64_en-US.msi"
Write-Host " Downloading URL Rewrite ..." -ForegroundColor Yellow
Invoke-WebRequest -Uri $url -OutFile $msi
Start-Process msiexec.exe -ArgumentList "/i `"$msi`" /quiet /norestart" -Wait
Remove-Item $msi -Force
Write-Host " [OK] URL Rewrite installed" -ForegroundColor Green
}
# ---------------------------------------------------------------------------
Write-Step "4/7 — Install .NET 10 SDK (for Gitea runner builds)"
# ---------------------------------------------------------------------------
if (Test-CommandExists 'dotnet') {
$v = (dotnet --list-sdks) -match '^10\.'
if ($v) {
Write-Host " [OK] .NET 10 SDK installed" -ForegroundColor Green
}
}
if (-not (Test-CommandExists 'dotnet') -or -not ((dotnet --list-sdks) -match '^10\.')) {
$url = 'https://builds.dotnet.microsoft.com/dotnet/Sdk/10.0.104/dotnet-sdk-10.0.104-win-x64.exe'
$exe = "$env:TEMP\dotnet-sdk-10.0.104-win-x64.exe"
Write-Host " Downloading .NET 10 SDK ..." -ForegroundColor Yellow
Invoke-WebRequest -Uri $url -OutFile $exe
Start-Process $exe -ArgumentList '/quiet','/norestart' -Wait
Remove-Item $exe -Force
Write-Host " [OK] .NET 10 SDK installed" -ForegroundColor Green
}
# ---------------------------------------------------------------------------
Write-Step "5/7 — Install Node.js 20 LTS"
# ---------------------------------------------------------------------------
if (Test-CommandExists 'node') {
$nodeV = (node --version) -replace 'v', ''
if ([version]$nodeV -ge [version]'20.0.0') {
Write-Host " [OK] Node.js $nodeV installed" -ForegroundColor Green
}
}
if (-not (Test-CommandExists 'node') -or ([version]((node --version) -replace 'v','')) -lt [version]'20.0.0') {
$url = 'https://nodejs.org/dist/v20.18.0/node-v20.18.0-x64.msi'
$msi = "$env:TEMP\node-v20.18.0-x64.msi"
Write-Host " Downloading Node.js 20 ..." -ForegroundColor Yellow
Invoke-WebRequest -Uri $url -OutFile $msi
Start-Process msiexec.exe -ArgumentList "/i `"$msi`" /quiet /norestart" -Wait
Remove-Item $msi -Force
Write-Host " [OK] Node.js 20 installed" -ForegroundColor Green
}
# ---------------------------------------------------------------------------
Write-Step "6/7 — Install Git for Windows"
# ---------------------------------------------------------------------------
if (Test-CommandExists 'git') {
Write-Host " [OK] Git installed" -ForegroundColor Green
}
else {
$url = 'https://github.com/git-for-windows/git/releases/download/v2.46.0.windows.1/Git-2.46.0-64-bit.exe'
$exe = "$env:TEMP\Git-2.46.0-64-bit.exe"
Write-Host " Downloading Git ..." -ForegroundColor Yellow
Invoke-WebRequest -Uri $url -OutFile $exe
Start-Process $exe -ArgumentList '/VERYSILENT','/NORESTART' -Wait
Remove-Item $exe -Force
Write-Host " [OK] Git installed" -ForegroundColor Green
}
# ---------------------------------------------------------------------------
Write-Step "7/7 — Install win-acme (Let's Encrypt client)"
# ---------------------------------------------------------------------------
$wacsPath = 'C:\Tools\win-acme'
if (Test-Path "$wacsPath\wacs.exe") {
Write-Host " [OK] win-acme already installed at $wacsPath" -ForegroundColor Green
}
else {
New-Item -ItemType Directory -Force -Path $wacsPath | Out-Null
$url = 'https://github.com/win-acme/win-acme/releases/download/v2.2.9.1701/win-acme.v2.2.9.1701.x64.pluggable.zip'
$zip = "$env:TEMP\wacs.zip"
Write-Host " Downloading win-acme ..." -ForegroundColor Yellow
Invoke-WebRequest -Uri $url -OutFile $zip
Expand-Archive -Path $zip -DestinationPath $wacsPath -Force
Remove-Item $zip -Force
Write-Host " [OK] win-acme installed at $wacsPath\wacs.exe" -ForegroundColor Green
}
# ---------------------------------------------------------------------------
Write-Host ""
Write-Host "=========================================================" -ForegroundColor Green
Write-Host " DONE — Prerequisites installed" -ForegroundColor Green
Write-Host "=========================================================" -ForegroundColor Green
Write-Host ""
Write-Host "Next steps:"
Write-Host " 1. Restart IIS: iisreset"
Write-Host " 2. Run: .\scripts\deployment\02-setup-sqlserver.ps1"
Write-Host " 3. Run: .\scripts\deployment\03-setup-iis-sites.ps1"
Write-Host ""
Write-Host "Refresh PATH in new PowerShell session to use dotnet/node/git"
+159
View File
@@ -0,0 +1,159 @@
# ============================================================================
# 03 — Setup 3 IIS sites: DYD.Api / DYD.User / DYD.Admin
# Run as Administrator on VPS after 01-install-prerequisites.ps1
# ============================================================================
#Requires -RunAsAdministrator
$ErrorActionPreference = 'Stop'
Import-Module WebAdministration -ErrorAction Stop
# --- CONFIG ------------------------------------------------------------------
$BaseDir = 'C:\inetpub'
$Sites = @(
@{
Name = 'DYD.Api'
AppPool = 'DYD.ApiPool'
Path = "$BaseDir\DYD.Api"
HttpPort = 5443
HttpsPort = 443
Host = 'api.ski-ump.com.vn'
LogsPath = "$BaseDir\DYD.Api\logs"
Managed = $true # .NET Core → No Managed Code
},
@{
Name = 'DYD.User'
AppPool = '' # static site, no app pool
Path = "$BaseDir\DYD.User"
HttpPort = 8080
HttpsPort = 443
Host = 'ski-ump.com.vn'
Managed = $false
},
@{
Name = 'DYD.Admin'
AppPool = ''
Path = "$BaseDir\DYD.Admin"
HttpPort = 8082
HttpsPort = 443
Host = 'admin.ski-ump.com.vn'
Managed = $false
}
)
# --- HELPER ------------------------------------------------------------------
function New-Dir($p) {
if (-not (Test-Path $p)) {
New-Item -ItemType Directory -Path $p -Force | Out-Null
Write-Host " Created: $p" -ForegroundColor Green
}
}
function New-ApiAppPool($name) {
if (Test-Path "IIS:\AppPools\$name") {
Write-Host " [OK] AppPool $name exists" -ForegroundColor Green
return
}
New-WebAppPool -Name $name | Out-Null
Set-ItemProperty "IIS:\AppPools\$name" -name managedRuntimeVersion -value ''
Set-ItemProperty "IIS:\AppPools\$name" -name startMode -value 'AlwaysRunning'
Set-ItemProperty "IIS:\AppPools\$name" -name processModel.idleTimeout -value ([TimeSpan]::Zero)
Write-Host " Created AppPool: $name (No Managed Code, AlwaysRunning)" -ForegroundColor Green
}
# --- DIRECTORIES -------------------------------------------------------------
Write-Host ""
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host " 1/4 — Create directories" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
foreach ($s in $Sites) {
New-Dir $s.Path
if ($s.LogsPath) { New-Dir $s.LogsPath }
}
New-Dir "$BaseDir\backups"
# --- APP POOLS ---------------------------------------------------------------
Write-Host ""
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host " 2/4 — Create App Pools" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
foreach ($s in $Sites) {
if ($s.Managed -and $s.AppPool) {
New-ApiAppPool $s.AppPool
}
}
# --- SITES -------------------------------------------------------------------
Write-Host ""
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host " 3/4 — Create Sites + Bindings" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
foreach ($s in $Sites) {
# Remove existing site if port conflict
$existing = Get-Website -Name $s.Name -ErrorAction SilentlyContinue
if ($existing) {
Write-Host " Site $($s.Name) exists. Skipping creation." -ForegroundColor Yellow
continue
}
if ($s.Managed) {
New-Website -Name $s.Name `
-PhysicalPath $s.Path `
-ApplicationPool $s.AppPool `
-Port $s.HttpPort `
-HostHeader $s.Host `
-Force | Out-Null
}
else {
New-Website -Name $s.Name `
-PhysicalPath $s.Path `
-Port $s.HttpPort `
-HostHeader $s.Host `
-Force | Out-Null
}
Write-Host " Created site: $($s.Name) on :$($s.HttpPort) — host: $($s.Host)" -ForegroundColor Green
}
# --- PLACEHOLDER INDEX -------------------------------------------------------
Write-Host ""
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host " 4/4 — Placeholder index.html" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
foreach ($s in $Sites) {
$indexPath = Join-Path $s.Path 'index.html'
if (-not (Test-Path $indexPath)) {
@"
<!DOCTYPE html>
<html><head><title>$($s.Name)</title></head>
<body><h1>$($s.Name) placeholder</h1>
<p>Site chưa đưc deploy. Chy pipeline hoc deploy th công.</p></body></html>
"@ | Set-Content $indexPath -Encoding UTF8
}
}
# --- FIREWALL ----------------------------------------------------------------
Write-Host ""
Write-Host "Firewall rules (HTTP 80, HTTPS 443, custom ports):" -ForegroundColor Cyan
$ports = @(80, 443, 5443, 8080, 8082, 3000) # 3000 cho Gitea sau
foreach ($p in $ports) {
$rule = "DYD-TCP-$p"
if (-not (Get-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue)) {
New-NetFirewallRule -DisplayName $rule -Direction Inbound -Protocol TCP -LocalPort $p -Action Allow | Out-Null
Write-Host " [+] TCP $p allowed" -ForegroundColor Green
}
}
# --- SUMMARY -----------------------------------------------------------------
Write-Host ""
Write-Host "=========================================================" -ForegroundColor Green
Write-Host " DONE — 3 IIS sites ready" -ForegroundColor Green
Write-Host "=========================================================" -ForegroundColor Green
Write-Host ""
Get-Website | Where-Object { $_.Name -like 'DYD.*' } | Format-Table -AutoSize Name, State, PhysicalPath, @{L='Bindings';E={($_.Bindings.Collection.bindingInformation) -join ', '}}
Write-Host "HTTP URLs (cần bind host file hoặc DNS):"
foreach ($s in $Sites) {
Write-Host " http://$($s.Host):$($s.HttpPort)" -ForegroundColor Yellow
}
Write-Host ""
Write-Host "Next: 04-install-gitea.ps1 + 05-setup-ssl.ps1 (sau khi DNS trỏ IP)"
+147
View File
@@ -0,0 +1,147 @@
# ============================================================================
# 04 — Install Gitea 1.23 + NSSM service (port 3000, SQLite)
# Run as Administrator. Pattern từ NamGroup skill cicd-gitea-windows.
# ============================================================================
#Requires -RunAsAdministrator
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$GiteaDir = 'C:\Gitea'
$DataDir = 'C:\Gitea\data'
$LogsDir = 'C:\Gitea\log'
$NSSMDir = 'C:\Tools\nssm'
# --- DOWNLOAD GITEA ---------------------------------------------------------
Write-Host "1/5 — Download Gitea 1.23.7" -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path $GiteaDir, $DataDir, $LogsDir | Out-Null
$giteaExe = "$GiteaDir\gitea.exe"
if (-not (Test-Path $giteaExe)) {
$url = 'https://dl.gitea.com/gitea/1.23.7/gitea-1.23.7-windows-4.0-amd64.exe'
Invoke-WebRequest -Uri $url -OutFile $giteaExe
Write-Host " [OK] gitea.exe downloaded" -ForegroundColor Green
} else {
Write-Host " [OK] gitea.exe exists" -ForegroundColor Green
}
# --- DOWNLOAD NSSM ----------------------------------------------------------
Write-Host "2/5 — Download NSSM" -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path $NSSMDir | Out-Null
$nssmExe = "$NSSMDir\nssm.exe"
if (-not (Test-Path $nssmExe)) {
$zip = "$env:TEMP\nssm.zip"
Invoke-WebRequest -Uri 'https://nssm.cc/release/nssm-2.24.zip' -OutFile $zip
Expand-Archive -Path $zip -DestinationPath "$env:TEMP\nssm-extract" -Force
Copy-Item "$env:TEMP\nssm-extract\nssm-2.24\win64\nssm.exe" $nssmExe -Force
Remove-Item $zip, "$env:TEMP\nssm-extract" -Recurse -Force
Write-Host " [OK] nssm.exe ready" -ForegroundColor Green
} else {
Write-Host " [OK] nssm.exe exists" -ForegroundColor Green
}
# --- CONFIG FILE -------------------------------------------------------------
Write-Host "3/5 — Create app.ini" -ForegroundColor Cyan
$appIni = "$GiteaDir\custom\conf\app.ini"
New-Item -ItemType Directory -Force -Path (Split-Path $appIni) | Out-Null
if (-not (Test-Path $appIni)) {
@'
APP_NAME = DYD Git (Gitea)
RUN_USER = LOCAL SYSTEM
RUN_MODE = prod
[server]
PROTOCOL = http
DOMAIN = 103.124.94.58
HTTP_ADDR = 127.0.0.1
HTTP_PORT = 3000
ROOT_URL = http://103.124.94.58:3000/
DISABLE_SSH = true
OFFLINE_MODE = false
[database]
DB_TYPE = sqlite3
PATH = C:/Gitea/data/gitea.db
LOG_SQL = false
[repository]
ROOT = C:/Gitea/data/gitea-repositories
[log]
MODE = file
LEVEL = info
ROOT_PATH = C:/Gitea/log
[security]
INSTALL_LOCK = false
; SECRET_KEY sẽ được tự sinh khi chạy lần đầu qua web installer
[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = true
ENABLE_NOTIFY_MAIL = false
REGISTER_EMAIL_CONFIRM = false
[picture]
DISABLE_GRAVATAR = true
[actions]
ENABLED = true
'@ | Set-Content -Path $appIni -Encoding UTF8
Write-Host " [OK] app.ini created" -ForegroundColor Green
} else {
Write-Host " [OK] app.ini exists (skip)" -ForegroundColor Green
}
# --- INSTALL SERVICE --------------------------------------------------------
Write-Host "4/5 — Install NSSM service 'gitea'" -ForegroundColor Cyan
$existingService = Get-Service -Name 'gitea' -ErrorAction SilentlyContinue
if (-not $existingService) {
& $nssmExe install gitea $giteaExe web --config "$appIni" --work-path "$GiteaDir"
& $nssmExe set gitea AppStdout "$LogsDir\stdout.log"
& $nssmExe set gitea AppStderr "$LogsDir\stderr.log"
& $nssmExe set gitea AppRotateFiles 1
& $nssmExe set gitea AppRotateBytes 10485760
& $nssmExe set gitea DisplayName 'Gitea Service (DYD)'
& $nssmExe set gitea Description 'Gitea self-hosted git + Actions for DYD project'
& $nssmExe set gitea Start SERVICE_AUTO_START
Write-Host " [OK] Service installed" -ForegroundColor Green
} else {
Write-Host " [OK] Service 'gitea' exists" -ForegroundColor Green
}
# --- START -------------------------------------------------------------------
Write-Host "5/5 — Start Gitea service" -ForegroundColor Cyan
Start-Service gitea
Start-Sleep 5
$svc = Get-Service gitea
Write-Host " Service status: $($svc.Status)" -ForegroundColor $(if ($svc.Status -eq 'Running') {'Green'} else {'Red'})
# --- Test HTTP ---------------------------------------------------------------
try {
$r = Invoke-WebRequest 'http://127.0.0.1:3000' -UseBasicParsing -TimeoutSec 10
Write-Host " Gitea web responds: HTTP $($r.StatusCode)" -ForegroundColor Green
} catch {
Write-Host " [WARN] Gitea chưa response, check log: $LogsDir" -ForegroundColor Yellow
}
# --- SUMMARY -----------------------------------------------------------------
Write-Host ""
Write-Host "=========================================================" -ForegroundColor Green
Write-Host " Gitea installed" -ForegroundColor Green
Write-Host "=========================================================" -ForegroundColor Green
Write-Host ""
Write-Host "Next steps MANUAL:" -ForegroundColor Yellow
Write-Host " 1. Mở browser: http://103.124.94.58:3000"
Write-Host " 2. Complete installer wizard (SQLite OK, admin account)"
Write-Host " 3. Sau khi setup: Site Admin → Disable Registration"
Write-Host " 4. Tạo repo 'DYD' và push code"
Write-Host " 5. Site Admin → Actions → Runner Management → Create Runner"
Write-Host " → copy registration token → chạy 05-install-act-runner.ps1"
Write-Host ""
Write-Host "Config file: $appIni"
Write-Host "Logs: $LogsDir"
@@ -0,0 +1,98 @@
# ============================================================================
# 05 — Install act_runner (Gitea Actions runner) as Windows service
# PREREQUISITE:
# 1. Gitea đã chạy (04-install-gitea.ps1)
# 2. Đã lấy registration token từ Gitea:
# Site Admin → Actions → Runner Management → Create new Runner → Copy token
# Usage:
# .\05-install-act-runner.ps1 -Token <registration-token>
# ============================================================================
param(
[Parameter(Mandatory=$true)]
[string]$Token,
[string]$GiteaUrl = 'http://127.0.0.1:3000',
[string]$RunnerName = 'dyd-windows-runner',
[string]$Labels = 'windows:host,windows-latest:host'
)
#Requires -RunAsAdministrator
$ErrorActionPreference = 'Stop'
$RunnerDir = 'C:\Tools\act_runner'
$NSSM = 'C:\Tools\nssm\nssm.exe'
if (-not (Test-Path $NSSM)) {
Write-Error "NSSM chưa cài. Chạy 04-install-gitea.ps1 trước."
exit 1
}
# --- DOWNLOAD ---------------------------------------------------------------
Write-Host "1/4 — Download act_runner" -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path $RunnerDir | Out-Null
$runnerExe = "$RunnerDir\act_runner.exe"
if (-not (Test-Path $runnerExe)) {
$url = 'https://dl.gitea.com/act_runner/0.2.11/act_runner-0.2.11-windows-amd64.exe'
Invoke-WebRequest -Uri $url -OutFile $runnerExe
Write-Host " [OK] downloaded" -ForegroundColor Green
}
# --- REGISTER ---------------------------------------------------------------
Write-Host "2/4 — Register with Gitea" -ForegroundColor Cyan
Set-Location $RunnerDir
if (-not (Test-Path "$RunnerDir\.runner")) {
& $runnerExe register --no-interactive `
--instance $GiteaUrl `
--token $Token `
--name $RunnerName `
--labels $Labels
if ($LASTEXITCODE -ne 0) {
Write-Error "Register failed. Check token + Gitea URL."
exit 1
}
Write-Host " [OK] Registered as $RunnerName" -ForegroundColor Green
} else {
Write-Host " [OK] Already registered (.runner exists)" -ForegroundColor Green
}
# --- CONFIG -----------------------------------------------------------------
Write-Host "3/4 — Generate config.yaml" -ForegroundColor Cyan
$cfgFile = "$RunnerDir\config.yaml"
if (-not (Test-Path $cfgFile)) {
& $runnerExe generate-config | Set-Content $cfgFile -Encoding UTF8
Write-Host " [OK] config.yaml ready — edit nếu cần tùy chỉnh" -ForegroundColor Green
}
# --- SERVICE ----------------------------------------------------------------
Write-Host "4/4 — Install NSSM service 'act_runner'" -ForegroundColor Cyan
$svc = Get-Service -Name 'act_runner' -ErrorAction SilentlyContinue
if (-not $svc) {
& $NSSM install act_runner $runnerExe daemon --config "$cfgFile"
& $NSSM set act_runner AppDirectory $RunnerDir
& $NSSM set act_runner AppStdout "$RunnerDir\runner-stdout.log"
& $NSSM set act_runner AppStderr "$RunnerDir\runner-stderr.log"
& $NSSM set act_runner AppRotateFiles 1
& $NSSM set act_runner AppRotateBytes 10485760
& $NSSM set act_runner DisplayName 'Gitea Actions Runner (DYD)'
& $NSSM set act_runner Start SERVICE_AUTO_START
Write-Host " [OK] Service installed" -ForegroundColor Green
}
Start-Service act_runner
Start-Sleep 3
$svc = Get-Service act_runner
Write-Host " Service status: $($svc.Status)" -ForegroundColor $(if ($svc.Status -eq 'Running') {'Green'} else {'Red'})
# --- DONE -------------------------------------------------------------------
Write-Host ""
Write-Host "=========================================================" -ForegroundColor Green
Write-Host " act_runner ready — Gitea Actions sẵn sàng" -ForegroundColor Green
Write-Host "=========================================================" -ForegroundColor Green
Write-Host ""
Write-Host "Verify:"
Write-Host " Gitea UI → Site Admin → Actions → Runner — phải thấy '$RunnerName' online"
Write-Host ""
Write-Host "Labels: $Labels"
Write-Host "Logs: $RunnerDir\runner-*.log"
+114
View File
@@ -0,0 +1,114 @@
# ============================================================================
# 06 — Setup Let's Encrypt SSL cho 3 domain qua win-acme
# PREREQUISITE:
# - DNS đã trỏ 3 domain về IP VPS (103.124.94.58)
# - IIS sites đã bind với host header đúng
# - Port 80 mở từ internet (cho HTTP-01 challenge)
# ============================================================================
#Requires -RunAsAdministrator
$ErrorActionPreference = 'Stop'
$wacs = 'C:\Tools\win-acme\wacs.exe'
$email = 'admin@ski-ump.com.vn' # TODO: đổi email contact
if (-not (Test-Path $wacs)) {
Write-Error "win-acme chưa cài. Chạy 01-install-prerequisites.ps1 trước."
exit 1
}
# --- CHECK DNS ---------------------------------------------------------------
$domains = @('api.ski-ump.com.vn', 'ski-ump.com.vn', 'admin.ski-ump.com.vn')
$vpsIp = '103.124.94.58'
Write-Host "Checking DNS resolution (phải trỏ về $vpsIp) ..." -ForegroundColor Cyan
$allOk = $true
foreach ($d in $domains) {
try {
$resolved = (Resolve-DnsName $d -Type A -ErrorAction Stop | Where-Object { $_.Type -eq 'A' } | Select-Object -First 1).IPAddress
if ($resolved -eq $vpsIp) {
Write-Host " [OK] $d -> $resolved" -ForegroundColor Green
} else {
Write-Host " [FAIL] $d -> $resolved (expected $vpsIp)" -ForegroundColor Red
$allOk = $false
}
} catch {
Write-Host " [FAIL] $d -> DNS not resolvable" -ForegroundColor Red
$allOk = $false
}
}
if (-not $allOk) {
Write-Host ""
Write-Host "DNS chưa sẵn sàng. Cần cấu hình A record cho 3 domain về $vpsIp trước." -ForegroundColor Red
Write-Host "Vào DNS provider (Cloudflare/GoDaddy/...) tạo:" -ForegroundColor Yellow
Write-Host " api.ski-ump.com.vn A $vpsIp"
Write-Host " ski-ump.com.vn A $vpsIp"
Write-Host " admin.ski-ump.com.vn A $vpsIp"
Write-Host " (plus) www.ski-ump.com.vn CNAME ski-ump.com.vn"
Write-Host ""
Write-Host "Đợi ~15 phút cho DNS propagation rồi chạy lại script này."
exit 1
}
# --- ISSUE CERTIFICATE -------------------------------------------------------
Write-Host ""
Write-Host "Issuing Let's Encrypt cert cho 3 domain..." -ForegroundColor Cyan
$sites = @(
@{ Domain = 'api.ski-ump.com.vn'; IISSite = 'DYD.Api' },
@{ Domain = 'ski-ump.com.vn'; IISSite = 'DYD.User' },
@{ Domain = 'admin.ski-ump.com.vn'; IISSite = 'DYD.Admin' }
)
foreach ($s in $sites) {
Write-Host ""
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host " SSL for $($s.Domain) -> $($s.IISSite)" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
# Non-interactive mode
& $wacs `
--target manual `
--host $s.Domain `
--installation iis `
--installationsiteid (Get-Website -Name $s.IISSite).Id `
--emailaddress $email `
--accepttos
if ($LASTEXITCODE -ne 0) {
Write-Host " [FAIL] Issue cert for $($s.Domain) (exit $LASTEXITCODE)" -ForegroundColor Red
} else {
Write-Host " [OK] Cert issued and bound to $($s.IISSite)" -ForegroundColor Green
}
}
# --- FORCE HTTPS REDIRECT ----------------------------------------------------
Write-Host ""
Write-Host "Adding HTTP→HTTPS redirect on all 3 sites..." -ForegroundColor Cyan
# Trên mỗi site, URL Rewrite rule để 301 redirect HTTP → HTTPS
# Có thể làm ở web.config nhưng cho API phải cẩn thận (stdout log)
# Simplest: enable HTTPS only binding, remove HTTP binding
foreach ($s in $sites) {
# Không remove HTTP binding vì Let's Encrypt cần port 80 để renew
# Dùng URL Rewrite trong web.config hoặc HSTS
Write-Host " $($s.IISSite): HTTPS cert đã bound. (HTTP binding để Let's Encrypt renew)" -ForegroundColor Yellow
}
# --- VERIFY ------------------------------------------------------------------
Write-Host ""
Write-Host "Verify bindings:" -ForegroundColor Cyan
Get-WebBinding | Where-Object { $_.bindingInformation -match 'dyd' } | Format-Table protocol, bindingInformation -AutoSize
Write-Host ""
Write-Host "=========================================================" -ForegroundColor Green
Write-Host " SSL setup done" -ForegroundColor Green
Write-Host "=========================================================" -ForegroundColor Green
Write-Host ""
Write-Host "Test:"
foreach ($s in $sites) {
Write-Host " curl -I https://$($s.Domain)"
}
Write-Host ""
Write-Host "Auto-renewal: win-acme tạo Scheduled Task tự động (daily check, renew 30 ngày trước expiry)."
@@ -0,0 +1,52 @@
# Hướng dẫn mở SSH trên VPS (1 lần duy nhất)
## Cách dễ nhất — copy script vào RDP
1. **RDP vào VPS:**
- Windows + R → `mstsc`
- Computer: `103.124.94.58:3389`
- User: `Administrator`
- Password: (dùng password đã cung cấp)
2. **Mở PowerShell AS ADMINISTRATOR:**
- Start → gõ "PowerShell" → right-click → **Run as Administrator**
3. **Copy-paste toàn bộ file `scripts/deployment/00-enable-ssh-server.ps1`** vào PowerShell → Enter.
4. **Đợi ~1 phút**, khi thấy `DONE — SSH server ready` là xong.
5. **Báo lại cho dev**, dev sẽ test connection:
```bash
ssh -i ~/.ssh/dyd_vps Administrator@103.124.94.58 hostname
```
## Nếu copy-paste qua RDP clipboard không được
**Option A — Upload file qua RDP mapped drive:**
1. Trong mstsc → More → Local Resources → Drives → check `D:\` (hoặc drive chứa repo)
2. Sau khi RDP, mở File Explorer → `\\tsclient\D\...\scripts\deployment\00-enable-ssh-server.ps1`
3. Right-click file → Run with PowerShell (As Admin)
**Option B — Tải từ Gitea/GitHub (sau khi push):**
```powershell
Invoke-WebRequest -Uri '<raw-url>/scripts/deployment/00-enable-ssh-server.ps1' -OutFile C:\Temp\00-enable-ssh.ps1
powershell -ExecutionPolicy Bypass -File C:\Temp\00-enable-ssh.ps1
```
## Script làm gì?
1. Install OpenSSH Server capability (Windows feature)
2. Start `sshd` service + set auto-start
3. Open firewall port 22
4. Set default SSH shell = PowerShell (chứ không phải cmd)
5. Add public key `dyd-vps-deploy-20260415` vào `administrators_authorized_keys`
6. Fix file permission (chỉ Admin + SYSTEM được đọc)
**Idempotent** — chạy nhiều lần OK, không side effect.
## Security
- Public key đã embed trong script
- Private key nằm trên máy dev (`~/.ssh/dyd_vps`) — KHÔNG rời máy
- Nếu mất private key → chạy lại script sau khi đổi `$PublicKey` với key mới
- Disable SSH sau khi xong deploy: `Stop-Service sshd; Set-Service sshd -StartupType Disabled`
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
web.config cho DYD.Api — ASP.NET Core 10 out-of-process / in-process hosting
Đặt ở: C:\inetpub\DYD.Api\web.config
Robocopy /XF web.config để không bị overwrite khi deploy.
-->
<configuration>
<location path="." inheritInChildApplications="false">
<system.webServer>
<handlers>
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
</handlers>
<aspNetCore processPath="dotnet"
arguments=".\DYD.Api.dll"
stdoutLogEnabled="true"
stdoutLogFile=".\logs\stdout"
hostingModel="InProcess"
forwardWindowsAuthToken="false">
<environmentVariables>
<environmentVariable name="ASPNETCORE_ENVIRONMENT" value="Production" />
<!-- Nếu dùng User Secrets / env var cho JWT key, set ở đây hoặc qua IIS Configuration Editor -->
<!--
<environmentVariable name="ConnectionStrings__DefaultConnection" value="Server=..." />
<environmentVariable name="Jwt__SigningKey" value="..." />
-->
</environmentVariables>
</aspNetCore>
<!-- Security headers -->
<httpProtocol>
<customHeaders>
<add name="X-Content-Type-Options" value="nosniff" />
<add name="X-Frame-Options" value="DENY" />
<add name="Referrer-Policy" value="strict-origin-when-cross-origin" />
<add name="Permissions-Policy" value="geolocation=(), microphone=(), camera=()" />
</customHeaders>
</httpProtocol>
<!-- Gzip compression -->
<httpCompression>
<dynamicTypes>
<add mimeType="application/json" enabled="true" />
<add mimeType="application/json; charset=utf-8" enabled="true" />
</dynamicTypes>
</httpCompression>
<!-- Larger file upload (cho InitiativeAttachments) -->
<security>
<requestFiltering>
<!-- 50MB = 52428800 bytes -->
<requestLimits maxAllowedContentLength="52428800" />
</requestFiltering>
</security>
</system.webServer>
</location>
</configuration>
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
web.config cho React SPA (fe0 / fe-admin)
Đặt ở: C:\inetpub\DYD.User\web.config và C:\inetpub\DYD.Admin\web.config
Mục đích:
- URL Rewrite: React Router HTML5 history API → fallback /index.html
- Cache static assets 1 năm (hash filename đảm bảo bust cache khi build mới)
- Security headers
- MIME types cho font/webmanifest
Deploy: Robocopy /XF web.config để không bị overwrite.
-->
<configuration>
<system.webServer>
<!-- React Router rewrite -->
<rewrite>
<rules>
<rule name="React Router SPA" stopProcessing="true">
<match url=".*" />
<conditions logicalGrouping="MatchAll">
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
<!-- Không rewrite /api/* (nếu có reverse proxy) -->
<add input="{REQUEST_URI}" pattern="^/(api)" negate="true" />
</conditions>
<action type="Rewrite" url="/index.html" />
</rule>
</rules>
</rewrite>
<!-- MIME types -->
<staticContent>
<remove fileExtension=".webmanifest" />
<mimeMap fileExtension=".webmanifest" mimeType="application/manifest+json" />
<remove fileExtension=".woff2" />
<mimeMap fileExtension=".woff2" mimeType="font/woff2" />
<!-- Cache static 1 năm (Vite build hash filename bust cache tự động) -->
<clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="365.00:00:00" />
</staticContent>
<!-- Security headers -->
<httpProtocol>
<customHeaders>
<add name="X-Content-Type-Options" value="nosniff" />
<add name="X-Frame-Options" value="DENY" />
<add name="Referrer-Policy" value="strict-origin-when-cross-origin" />
<add name="Permissions-Policy" value="geolocation=(), microphone=(), camera=()" />
<!-- CSP strict (điều chỉnh nếu cần CDN): -->
<!-- <add name="Content-Security-Policy" value="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.ski-ump.com.vn" /> -->
</customHeaders>
</httpProtocol>
<!-- Gzip compression -->
<httpCompression>
<dynamicTypes>
<add mimeType="text/*" enabled="true" />
<add mimeType="application/javascript" enabled="true" />
<add mimeType="application/json" enabled="true" />
</dynamicTypes>
<staticTypes>
<add mimeType="text/*" enabled="true" />
<add mimeType="application/javascript" enabled="true" />
<add mimeType="application/json" enabled="true" />
<add mimeType="application/manifest+json" enabled="true" />
</staticTypes>
</httpCompression>
<!-- Default document -->
<defaultDocument>
<files>
<clear />
<add value="index.html" />
</files>
</defaultDocument>
</system.webServer>
</configuration>
+56
View File
@@ -0,0 +1,56 @@
# VPS inventory check
$ErrorActionPreference = 'Continue'
Write-Host "=== OS ===" -ForegroundColor Cyan
$os = Get-WmiObject Win32_OperatingSystem
$os | Select Caption, Version, OSArchitecture | Format-List
"Memory: $([math]::Round($os.TotalVisibleMemorySize / 1MB, 2)) GB"
Write-Host "=== CPU ===" -ForegroundColor Cyan
Get-WmiObject Win32_Processor | Select Name, NumberOfCores | Format-List
Write-Host "=== Disks ===" -ForegroundColor Cyan
Get-PSDrive -PSProvider FileSystem | Where-Object Used -gt 0 | ForEach-Object {
"Drive $($_.Name): Used $([math]::Round($_.Used / 1GB, 2)) GB, Free $([math]::Round($_.Free / 1GB, 2)) GB"
}
Write-Host "=== IIS ===" -ForegroundColor Cyan
$iis = Get-WindowsFeature Web-Server -ErrorAction SilentlyContinue
if ($iis) { "IIS: $($iis.InstallState)" } else { "IIS: Not available via WindowsFeature" }
$w3svc = Get-Service W3SVC -ErrorAction SilentlyContinue
if ($w3svc) { "W3SVC service: $($w3svc.Status)" }
Write-Host "=== .NET SDKs ===" -ForegroundColor Cyan
try { dotnet --list-sdks } catch { "dotnet not found" }
Write-Host "=== Node.js ===" -ForegroundColor Cyan
try { node --version } catch { "node not found" }
try { npm --version } catch { "npm not found" }
Write-Host "=== Git ===" -ForegroundColor Cyan
try { git --version } catch { "git not found" }
Write-Host "=== SQL Server services ===" -ForegroundColor Cyan
Get-Service MSSQL* -ErrorAction SilentlyContinue | Select Name, Status, StartType | Format-Table -AutoSize
Get-Service SQL* -ErrorAction SilentlyContinue | Select Name, Status, StartType | Format-Table -AutoSize
Write-Host "=== PowerShell ===" -ForegroundColor Cyan
$PSVersionTable.PSVersion
Write-Host "=== Listening ports ===" -ForegroundColor Cyan
Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue |
Where-Object { $_.LocalPort -in 22,80,443,1433,3000,3389,5443,5985,8080,8082 } |
Select-Object LocalAddress, LocalPort |
Sort-Object LocalPort |
Get-Unique -AsString |
Format-Table -AutoSize
Write-Host "=== ASP.NET Core Module ===" -ForegroundColor Cyan
$hb = Test-Path 'HKLM:\SOFTWARE\Microsoft\ASP.NET Core\Hosting Bundle\v10.0'
"ASP.NET Core 10 Hosting Bundle: $(if ($hb) {'Installed'} else {'NOT installed'})"
$hb9 = Test-Path 'HKLM:\SOFTWARE\Microsoft\ASP.NET Core\Hosting Bundle\v9.0'
"ASP.NET Core 9 Hosting Bundle: $(if ($hb9) {'Installed'} else {'NOT installed'})"
Write-Host "=== URL Rewrite ===" -ForegroundColor Cyan
$ur = Test-Path 'HKLM:\SOFTWARE\Microsoft\IIS Extensions\URL Rewrite'
"URL Rewrite: $(if ($ur) {'Installed'} else {'NOT installed'})"
@@ -0,0 +1,78 @@
-- ============================================================================
-- DYD — Create Production Database + Dedicated Login
-- Run on SQL Server as sysadmin (sa)
-- ============================================================================
USE [master];
GO
-- 1. Create database
IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = N'DYD_Prod')
BEGIN
CREATE DATABASE [DYD_Prod]
COLLATE Vietnamese_CI_AS;
-- Optional: set recovery model (Simple cho dev/staging, Full cho prod backup log)
ALTER DATABASE [DYD_Prod] SET RECOVERY SIMPLE;
PRINT 'Database [DYD_Prod] created.';
END
ELSE
BEGIN
PRINT 'Database [DYD_Prod] already exists. Skipping.';
END
GO
-- 2. Create dedicated login for the app (KHÔNG dùng sa trong production)
-- TODO: Thay '<APP_DB_PASSWORD>' bằng password mạnh (32+ char, random)
-- Generate: [System.Web.Security.Membership]::GeneratePassword(32, 8)
IF NOT EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = N'dyd_app')
BEGIN
CREATE LOGIN [dyd_app] WITH
PASSWORD = N'<APP_DB_PASSWORD>',
DEFAULT_DATABASE = [DYD_Prod],
CHECK_EXPIRATION = OFF,
CHECK_POLICY = ON;
PRINT 'Login [dyd_app] created.';
END
ELSE
BEGIN
PRINT 'Login [dyd_app] already exists. Skipping.';
END
GO
-- 3. Map login to database user + assign roles
USE [DYD_Prod];
GO
IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = N'dyd_app')
BEGIN
CREATE USER [dyd_app] FOR LOGIN [dyd_app];
PRINT 'User [dyd_app] created in [DYD_Prod].';
END
GO
-- Grant roles:
-- db_datareader — SELECT
-- db_datawriter — INSERT, UPDATE, DELETE
-- db_ddladmin — CREATE/ALTER/DROP (cho EF migrations)
ALTER ROLE db_datareader ADD MEMBER [dyd_app];
ALTER ROLE db_datawriter ADD MEMBER [dyd_app];
ALTER ROLE db_ddladmin ADD MEMBER [dyd_app];
PRINT 'Roles granted to [dyd_app].';
GO
-- 4. Verify
SELECT
DB_NAME() AS Database_Name,
USER_NAME() AS Current_User,
@@VERSION AS Server_Version;
GO
PRINT '';
PRINT '==========================================';
PRINT ' DONE — DYD_Prod ready.';
PRINT '==========================================';
PRINT '';
PRINT 'Connection string cho .NET (điền password đã tạo):';
PRINT 'Server=103.124.94.58,1433;Database=DYD_Prod;User Id=dyd_app;Password=<APP_DB_PASSWORD>;TrustServerCertificate=True;MultipleActiveResultSets=True;';
GO
+653
View File
@@ -0,0 +1,653 @@
IF OBJECT_ID(N'[__EFMigrationsHistory]') IS NULL
BEGIN
CREATE TABLE [__EFMigrationsHistory] (
[MigrationId] nvarchar(150) NOT NULL,
[ProductVersion] nvarchar(32) NOT NULL,
CONSTRAINT [PK___EFMigrationsHistory] PRIMARY KEY ([MigrationId])
);
END;
GO
BEGIN TRANSACTION;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE TABLE [AppraisalTeams] (
[Id] uniqueidentifier NOT NULL,
[Name] nvarchar(200) NOT NULL,
[Description] nvarchar(max) NULL,
[IsActive] bit NOT NULL,
[CreatedAt] datetime2 NOT NULL,
[UpdatedAt] datetime2 NULL,
[CreatedBy] nvarchar(max) NULL,
[UpdatedBy] nvarchar(max) NULL,
[IsDeleted] bit NOT NULL,
[DeletedAt] datetime2 NULL,
[DeletedBy] nvarchar(max) NULL,
CONSTRAINT [PK_AppraisalTeams] PRIMARY KEY ([Id])
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE TABLE [AspNetRoles] (
[Id] nvarchar(450) NOT NULL,
[Name] nvarchar(256) NULL,
[NormalizedName] nvarchar(256) NULL,
[ConcurrencyStamp] nvarchar(max) NULL,
CONSTRAINT [PK_AspNetRoles] PRIMARY KEY ([Id])
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE TABLE [AspNetUsers] (
[Id] nvarchar(450) NOT NULL,
[FullName] nvarchar(max) NOT NULL,
[IsEnabled] bit NOT NULL,
[CreatedAt] datetime2 NOT NULL,
[LastLoginAt] datetime2 NULL,
[RefreshToken] nvarchar(max) NULL,
[RefreshTokenExpiresAt] datetime2 NULL,
[UnitId] uniqueidentifier NULL,
[UserName] nvarchar(256) NULL,
[NormalizedUserName] nvarchar(256) NULL,
[Email] nvarchar(256) NULL,
[NormalizedEmail] nvarchar(256) NULL,
[EmailConfirmed] bit NOT NULL,
[PasswordHash] nvarchar(max) NULL,
[SecurityStamp] nvarchar(max) NULL,
[ConcurrencyStamp] nvarchar(max) NULL,
[PhoneNumber] nvarchar(max) NULL,
[PhoneNumberConfirmed] bit NOT NULL,
[TwoFactorEnabled] bit NOT NULL,
[LockoutEnd] datetimeoffset NULL,
[LockoutEnabled] bit NOT NULL,
[AccessFailedCount] int NOT NULL,
CONSTRAINT [PK_AspNetUsers] PRIMARY KEY ([Id])
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE TABLE [AuditLogs] (
[Id] uniqueidentifier NOT NULL,
[EntityName] nvarchar(100) NOT NULL,
[EntityId] nvarchar(100) NOT NULL,
[Action] nvarchar(50) NOT NULL,
[ActorUserId] nvarchar(max) NULL,
[ActorDisplayName] nvarchar(max) NULL,
[PreviousValue] nvarchar(max) NULL,
[NewValue] nvarchar(max) NULL,
[Metadata] nvarchar(max) NULL,
[IpAddress] nvarchar(max) NULL,
[UserAgent] nvarchar(max) NULL,
[CreatedAt] datetime2 NOT NULL,
[UpdatedAt] datetime2 NULL,
[CreatedBy] nvarchar(max) NULL,
[UpdatedBy] nvarchar(max) NULL,
CONSTRAINT [PK_AuditLogs] PRIMARY KEY ([Id])
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE TABLE [Notifications] (
[Id] uniqueidentifier NOT NULL,
[RecipientUserId] nvarchar(450) NOT NULL,
[Title] nvarchar(200) NOT NULL,
[Message] nvarchar(max) NOT NULL,
[Type] nvarchar(20) NOT NULL,
[Link] nvarchar(max) NULL,
[IsRead] bit NOT NULL,
[ReadAt] datetime2 NULL,
[CreatedAt] datetime2 NOT NULL,
[UpdatedAt] datetime2 NULL,
[CreatedBy] nvarchar(max) NULL,
[UpdatedBy] nvarchar(max) NULL,
CONSTRAINT [PK_Notifications] PRIMARY KEY ([Id])
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE TABLE [SystemSettings] (
[Id] uniqueidentifier NOT NULL,
[Key] nvarchar(100) NOT NULL,
[Value] nvarchar(max) NOT NULL,
[Description] nvarchar(max) NULL,
[Category] nvarchar(50) NOT NULL,
[IsSecret] bit NOT NULL,
[CreatedAt] datetime2 NOT NULL,
[UpdatedAt] datetime2 NULL,
[CreatedBy] nvarchar(max) NULL,
[UpdatedBy] nvarchar(max) NULL,
[IsDeleted] bit NOT NULL,
[DeletedAt] datetime2 NULL,
[DeletedBy] nvarchar(max) NULL,
CONSTRAINT [PK_SystemSettings] PRIMARY KEY ([Id])
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE TABLE [Units] (
[Id] uniqueidentifier NOT NULL,
[Name] nvarchar(200) NOT NULL,
[Code] nvarchar(50) NOT NULL,
[Description] nvarchar(max) NULL,
[ParentUnitId] uniqueidentifier NULL,
[CreatedAt] datetime2 NOT NULL,
[UpdatedAt] datetime2 NULL,
[CreatedBy] nvarchar(max) NULL,
[UpdatedBy] nvarchar(max) NULL,
[IsDeleted] bit NOT NULL,
[DeletedAt] datetime2 NULL,
[DeletedBy] nvarchar(max) NULL,
CONSTRAINT [PK_Units] PRIMARY KEY ([Id]),
CONSTRAINT [FK_Units_Units_ParentUnitId] FOREIGN KEY ([ParentUnitId]) REFERENCES [Units] ([Id]) ON DELETE NO ACTION
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE TABLE [AppraisalTeamMembers] (
[Id] uniqueidentifier NOT NULL,
[AppraisalTeamId] uniqueidentifier NOT NULL,
[UserId] nvarchar(450) NOT NULL,
[Role] nvarchar(50) NOT NULL,
[IsChair] bit NOT NULL,
[CreatedAt] datetime2 NOT NULL,
[UpdatedAt] datetime2 NULL,
[CreatedBy] nvarchar(max) NULL,
[UpdatedBy] nvarchar(max) NULL,
CONSTRAINT [PK_AppraisalTeamMembers] PRIMARY KEY ([Id]),
CONSTRAINT [FK_AppraisalTeamMembers_AppraisalTeams_AppraisalTeamId] FOREIGN KEY ([AppraisalTeamId]) REFERENCES [AppraisalTeams] ([Id]) ON DELETE CASCADE
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE TABLE [AspNetRoleClaims] (
[Id] int NOT NULL IDENTITY,
[RoleId] nvarchar(450) NOT NULL,
[ClaimType] nvarchar(max) NULL,
[ClaimValue] nvarchar(max) NULL,
CONSTRAINT [PK_AspNetRoleClaims] PRIMARY KEY ([Id]),
CONSTRAINT [FK_AspNetRoleClaims_AspNetRoles_RoleId] FOREIGN KEY ([RoleId]) REFERENCES [AspNetRoles] ([Id]) ON DELETE CASCADE
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE TABLE [AspNetUserClaims] (
[Id] int NOT NULL IDENTITY,
[UserId] nvarchar(450) NOT NULL,
[ClaimType] nvarchar(max) NULL,
[ClaimValue] nvarchar(max) NULL,
CONSTRAINT [PK_AspNetUserClaims] PRIMARY KEY ([Id]),
CONSTRAINT [FK_AspNetUserClaims_AspNetUsers_UserId] FOREIGN KEY ([UserId]) REFERENCES [AspNetUsers] ([Id]) ON DELETE CASCADE
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE TABLE [AspNetUserLogins] (
[LoginProvider] nvarchar(450) NOT NULL,
[ProviderKey] nvarchar(450) NOT NULL,
[ProviderDisplayName] nvarchar(max) NULL,
[UserId] nvarchar(450) NOT NULL,
CONSTRAINT [PK_AspNetUserLogins] PRIMARY KEY ([LoginProvider], [ProviderKey]),
CONSTRAINT [FK_AspNetUserLogins_AspNetUsers_UserId] FOREIGN KEY ([UserId]) REFERENCES [AspNetUsers] ([Id]) ON DELETE CASCADE
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE TABLE [AspNetUserRoles] (
[UserId] nvarchar(450) NOT NULL,
[RoleId] nvarchar(450) NOT NULL,
CONSTRAINT [PK_AspNetUserRoles] PRIMARY KEY ([UserId], [RoleId]),
CONSTRAINT [FK_AspNetUserRoles_AspNetRoles_RoleId] FOREIGN KEY ([RoleId]) REFERENCES [AspNetRoles] ([Id]) ON DELETE CASCADE,
CONSTRAINT [FK_AspNetUserRoles_AspNetUsers_UserId] FOREIGN KEY ([UserId]) REFERENCES [AspNetUsers] ([Id]) ON DELETE CASCADE
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE TABLE [AspNetUserTokens] (
[UserId] nvarchar(450) NOT NULL,
[LoginProvider] nvarchar(450) NOT NULL,
[Name] nvarchar(450) NOT NULL,
[Value] nvarchar(max) NULL,
CONSTRAINT [PK_AspNetUserTokens] PRIMARY KEY ([UserId], [LoginProvider], [Name]),
CONSTRAINT [FK_AspNetUserTokens_AspNetUsers_UserId] FOREIGN KEY ([UserId]) REFERENCES [AspNetUsers] ([Id]) ON DELETE CASCADE
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE TABLE [Authors] (
[Id] uniqueidentifier NOT NULL,
[FullName] nvarchar(200) NOT NULL,
[Email] nvarchar(200) NOT NULL,
[PhoneNumber] nvarchar(max) NULL,
[Position] nvarchar(max) NULL,
[AcademicTitle] nvarchar(max) NULL,
[UnitId] uniqueidentifier NULL,
[UserId] nvarchar(max) NULL,
[CreatedAt] datetime2 NOT NULL,
[UpdatedAt] datetime2 NULL,
[CreatedBy] nvarchar(max) NULL,
[UpdatedBy] nvarchar(max) NULL,
[IsDeleted] bit NOT NULL,
[DeletedAt] datetime2 NULL,
[DeletedBy] nvarchar(max) NULL,
CONSTRAINT [PK_Authors] PRIMARY KEY ([Id]),
CONSTRAINT [FK_Authors_Units_UnitId] FOREIGN KEY ([UnitId]) REFERENCES [Units] ([Id]) ON DELETE SET NULL
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE TABLE [Initiatives] (
[Id] uniqueidentifier NOT NULL,
[Code] nvarchar(50) NOT NULL,
[Title] nvarchar(500) NOT NULL,
[Description] nvarchar(max) NOT NULL,
[ShortSummary] nvarchar(max) NULL,
[Objectives] nvarchar(max) NULL,
[ScopeOfApplication] nvarchar(max) NULL,
[ExpectedOutcomes] nvarchar(max) NULL,
[ActualOutcomes] nvarchar(max) NULL,
[EstimatedBudget] decimal(18,2) NULL,
[ActualBudget] decimal(18,2) NULL,
[StartDate] datetime2 NULL,
[EndDate] datetime2 NULL,
[SubmissionDate] datetime2 NULL,
[ApprovalDate] datetime2 NULL,
[Status] int NOT NULL,
[Category] int NOT NULL,
[Group] int NOT NULL,
[OwningUnitId] uniqueidentifier NOT NULL,
[SubmittedByUserId] nvarchar(max) NOT NULL,
[CreatedAt] datetime2 NOT NULL,
[UpdatedAt] datetime2 NULL,
[CreatedBy] nvarchar(max) NULL,
[UpdatedBy] nvarchar(max) NULL,
[IsDeleted] bit NOT NULL,
[DeletedAt] datetime2 NULL,
[DeletedBy] nvarchar(max) NULL,
CONSTRAINT [PK_Initiatives] PRIMARY KEY ([Id]),
CONSTRAINT [FK_Initiatives_Units_OwningUnitId] FOREIGN KEY ([OwningUnitId]) REFERENCES [Units] ([Id]) ON DELETE NO ACTION
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE TABLE [InitiativeAttachments] (
[Id] uniqueidentifier NOT NULL,
[InitiativeId] uniqueidentifier NOT NULL,
[FileName] nvarchar(260) NOT NULL,
[StoragePath] nvarchar(500) NOT NULL,
[ContentType] nvarchar(100) NOT NULL,
[FileSize] bigint NOT NULL,
[Description] nvarchar(max) NULL,
[Category] nvarchar(max) NULL,
[CreatedAt] datetime2 NOT NULL,
[UpdatedAt] datetime2 NULL,
[CreatedBy] nvarchar(max) NULL,
[UpdatedBy] nvarchar(max) NULL,
[IsDeleted] bit NOT NULL,
[DeletedAt] datetime2 NULL,
[DeletedBy] nvarchar(max) NULL,
CONSTRAINT [PK_InitiativeAttachments] PRIMARY KEY ([Id]),
CONSTRAINT [FK_InitiativeAttachments_Initiatives_InitiativeId] FOREIGN KEY ([InitiativeId]) REFERENCES [Initiatives] ([Id]) ON DELETE CASCADE
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE TABLE [InitiativeAuthors] (
[Id] uniqueidentifier NOT NULL,
[InitiativeId] uniqueidentifier NOT NULL,
[AuthorId] uniqueidentifier NOT NULL,
[ContributionPercentage] decimal(5,2) NOT NULL,
[IsLeadAuthor] bit NOT NULL,
[ContributionDescription] nvarchar(max) NULL,
[CreatedAt] datetime2 NOT NULL,
[UpdatedAt] datetime2 NULL,
[CreatedBy] nvarchar(max) NULL,
[UpdatedBy] nvarchar(max) NULL,
CONSTRAINT [PK_InitiativeAuthors] PRIMARY KEY ([Id]),
CONSTRAINT [FK_InitiativeAuthors_Authors_AuthorId] FOREIGN KEY ([AuthorId]) REFERENCES [Authors] ([Id]) ON DELETE NO ACTION,
CONSTRAINT [FK_InitiativeAuthors_Initiatives_InitiativeId] FOREIGN KEY ([InitiativeId]) REFERENCES [Initiatives] ([Id]) ON DELETE CASCADE
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE TABLE [InitiativeStatusHistories] (
[Id] uniqueidentifier NOT NULL,
[InitiativeId] uniqueidentifier NOT NULL,
[FromStatus] int NOT NULL,
[ToStatus] int NOT NULL,
[Comment] nvarchar(max) NULL,
[ChangedByUserId] nvarchar(max) NOT NULL,
[CreatedAt] datetime2 NOT NULL,
[UpdatedAt] datetime2 NULL,
[CreatedBy] nvarchar(max) NULL,
[UpdatedBy] nvarchar(max) NULL,
CONSTRAINT [PK_InitiativeStatusHistories] PRIMARY KEY ([Id]),
CONSTRAINT [FK_InitiativeStatusHistories_Initiatives_InitiativeId] FOREIGN KEY ([InitiativeId]) REFERENCES [Initiatives] ([Id]) ON DELETE CASCADE
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE TABLE [Reviews] (
[Id] uniqueidentifier NOT NULL,
[InitiativeId] uniqueidentifier NOT NULL,
[ReviewerUserId] nvarchar(max) NOT NULL,
[AppraisalTeamId] uniqueidentifier NOT NULL,
[Score] decimal(5,2) NULL,
[NoveltyScore] decimal(5,2) NULL,
[FeasibilityScore] decimal(5,2) NULL,
[ImpactScore] decimal(5,2) NULL,
[EfficiencyScore] decimal(5,2) NULL,
[Decision] int NOT NULL,
[Comments] nvarchar(max) NULL,
[Strengths] nvarchar(max) NULL,
[Weaknesses] nvarchar(max) NULL,
[Recommendations] nvarchar(max) NULL,
[ReviewedAt] datetime2 NULL,
[DueDate] datetime2 NULL,
[CreatedAt] datetime2 NOT NULL,
[UpdatedAt] datetime2 NULL,
[CreatedBy] nvarchar(max) NULL,
[UpdatedBy] nvarchar(max) NULL,
[IsDeleted] bit NOT NULL,
[DeletedAt] datetime2 NULL,
[DeletedBy] nvarchar(max) NULL,
CONSTRAINT [PK_Reviews] PRIMARY KEY ([Id]),
CONSTRAINT [FK_Reviews_AppraisalTeams_AppraisalTeamId] FOREIGN KEY ([AppraisalTeamId]) REFERENCES [AppraisalTeams] ([Id]) ON DELETE NO ACTION,
CONSTRAINT [FK_Reviews_Initiatives_InitiativeId] FOREIGN KEY ([InitiativeId]) REFERENCES [Initiatives] ([Id]) ON DELETE CASCADE
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE UNIQUE INDEX [IX_AppraisalTeamMembers_AppraisalTeamId_UserId] ON [AppraisalTeamMembers] ([AppraisalTeamId], [UserId]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE INDEX [IX_AspNetRoleClaims_RoleId] ON [AspNetRoleClaims] ([RoleId]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
EXEC(N'CREATE UNIQUE INDEX [RoleNameIndex] ON [AspNetRoles] ([NormalizedName]) WHERE [NormalizedName] IS NOT NULL');
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE INDEX [IX_AspNetUserClaims_UserId] ON [AspNetUserClaims] ([UserId]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE INDEX [IX_AspNetUserLogins_UserId] ON [AspNetUserLogins] ([UserId]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE INDEX [IX_AspNetUserRoles_RoleId] ON [AspNetUserRoles] ([RoleId]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE INDEX [EmailIndex] ON [AspNetUsers] ([NormalizedEmail]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
EXEC(N'CREATE UNIQUE INDEX [UserNameIndex] ON [AspNetUsers] ([NormalizedUserName]) WHERE [NormalizedUserName] IS NOT NULL');
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE INDEX [IX_AuditLogs_CreatedAt] ON [AuditLogs] ([CreatedAt]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE INDEX [IX_AuditLogs_EntityName_EntityId] ON [AuditLogs] ([EntityName], [EntityId]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE INDEX [IX_Authors_Email] ON [Authors] ([Email]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE INDEX [IX_Authors_UnitId] ON [Authors] ([UnitId]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE INDEX [IX_InitiativeAttachments_InitiativeId] ON [InitiativeAttachments] ([InitiativeId]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE INDEX [IX_InitiativeAuthors_AuthorId] ON [InitiativeAuthors] ([AuthorId]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE UNIQUE INDEX [IX_InitiativeAuthors_InitiativeId_AuthorId] ON [InitiativeAuthors] ([InitiativeId], [AuthorId]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE INDEX [IX_Initiatives_Category] ON [Initiatives] ([Category]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE UNIQUE INDEX [IX_Initiatives_Code] ON [Initiatives] ([Code]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE INDEX [IX_Initiatives_OwningUnitId] ON [Initiatives] ([OwningUnitId]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE INDEX [IX_Initiatives_Status] ON [Initiatives] ([Status]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE INDEX [IX_InitiativeStatusHistories_InitiativeId] ON [InitiativeStatusHistories] ([InitiativeId]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE INDEX [IX_Notifications_RecipientUserId_IsRead] ON [Notifications] ([RecipientUserId], [IsRead]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE INDEX [IX_Reviews_AppraisalTeamId] ON [Reviews] ([AppraisalTeamId]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE INDEX [IX_Reviews_InitiativeId] ON [Reviews] ([InitiativeId]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE UNIQUE INDEX [IX_SystemSettings_Key] ON [SystemSettings] ([Key]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE UNIQUE INDEX [IX_Units_Code] ON [Units] ([Code]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
CREATE INDEX [IX_Units_ParentUnitId] ON [Units] ([ParentUnitId]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260415141734_InitialCreate'
)
BEGIN
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
VALUES (N'20260415141734_InitialCreate', N'10.0.6');
END;
COMMIT;
GO
@@ -0,0 +1,175 @@
BEGIN TRANSACTION;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
)
BEGIN
CREATE TABLE [InitiativeReports] (
[Id] uniqueidentifier NOT NULL,
[Code] nvarchar(50) NOT NULL,
[InitiativeId] uniqueidentifier NOT NULL,
[ActualOutcomes] nvarchar(4000) NULL,
[ActualBudget] decimal(18,2) NULL,
[ImplementationNotes] nvarchar(4000) NULL,
[Challenges] nvarchar(4000) NULL,
[LessonsLearned] nvarchar(4000) NULL,
[Status] int NOT NULL,
[SubmissionDate] datetime2 NULL,
[ApprovalDate] datetime2 NULL,
[SubmittedByUserId] nvarchar(450) NULL,
[CreatedAt] datetime2 NOT NULL,
[UpdatedAt] datetime2 NULL,
[CreatedBy] nvarchar(max) NULL,
[UpdatedBy] nvarchar(max) NULL,
[IsDeleted] bit NOT NULL,
[DeletedAt] datetime2 NULL,
[DeletedBy] nvarchar(max) NULL,
CONSTRAINT [PK_InitiativeReports] PRIMARY KEY ([Id]),
CONSTRAINT [FK_InitiativeReports_Initiatives_InitiativeId] FOREIGN KEY ([InitiativeId]) REFERENCES [Initiatives] ([Id]) ON DELETE NO ACTION
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
)
BEGIN
CREATE TABLE [RecognitionDocuments] (
[Id] uniqueidentifier NOT NULL,
[Code] nvarchar(50) NOT NULL,
[ReportId] uniqueidentifier NOT NULL,
[Type] int NOT NULL,
[Content] nvarchar(max) NULL,
[Summary] nvarchar(1000) NULL,
[Status] int NOT NULL,
[SubmissionDate] datetime2 NULL,
[ApprovalDate] datetime2 NULL,
[SubmittedByUserId] nvarchar(450) NULL,
[CreatedAt] datetime2 NOT NULL,
[UpdatedAt] datetime2 NULL,
[CreatedBy] nvarchar(max) NULL,
[UpdatedBy] nvarchar(max) NULL,
[IsDeleted] bit NOT NULL,
[DeletedAt] datetime2 NULL,
[DeletedBy] nvarchar(max) NULL,
CONSTRAINT [PK_RecognitionDocuments] PRIMARY KEY ([Id]),
CONSTRAINT [FK_RecognitionDocuments_InitiativeReports_ReportId] FOREIGN KEY ([ReportId]) REFERENCES [InitiativeReports] ([Id]) ON DELETE CASCADE
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
)
BEGIN
CREATE TABLE [ReportStatusHistories] (
[Id] uniqueidentifier NOT NULL,
[ReportId] uniqueidentifier NOT NULL,
[FromStatus] int NOT NULL,
[ToStatus] int NOT NULL,
[Comment] nvarchar(2000) NULL,
[ChangedByUserId] nvarchar(450) NOT NULL,
[CreatedAt] datetime2 NOT NULL,
[UpdatedAt] datetime2 NULL,
[CreatedBy] nvarchar(max) NULL,
[UpdatedBy] nvarchar(max) NULL,
[IsDeleted] bit NOT NULL,
[DeletedAt] datetime2 NULL,
[DeletedBy] nvarchar(max) NULL,
CONSTRAINT [PK_ReportStatusHistories] PRIMARY KEY ([Id]),
CONSTRAINT [FK_ReportStatusHistories_InitiativeReports_ReportId] FOREIGN KEY ([ReportId]) REFERENCES [InitiativeReports] ([Id]) ON DELETE CASCADE
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
)
BEGIN
CREATE TABLE [DocumentStatusHistories] (
[Id] uniqueidentifier NOT NULL,
[DocumentId] uniqueidentifier NOT NULL,
[FromStatus] int NOT NULL,
[ToStatus] int NOT NULL,
[Comment] nvarchar(2000) NULL,
[ChangedByUserId] nvarchar(450) NOT NULL,
[CreatedAt] datetime2 NOT NULL,
[UpdatedAt] datetime2 NULL,
[CreatedBy] nvarchar(max) NULL,
[UpdatedBy] nvarchar(max) NULL,
[IsDeleted] bit NOT NULL,
[DeletedAt] datetime2 NULL,
[DeletedBy] nvarchar(max) NULL,
CONSTRAINT [PK_DocumentStatusHistories] PRIMARY KEY ([Id]),
CONSTRAINT [FK_DocumentStatusHistories_RecognitionDocuments_DocumentId] FOREIGN KEY ([DocumentId]) REFERENCES [RecognitionDocuments] ([Id]) ON DELETE CASCADE
);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
)
BEGIN
CREATE INDEX [IX_DocumentStatusHistories_DocumentId] ON [DocumentStatusHistories] ([DocumentId]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
)
BEGIN
CREATE UNIQUE INDEX [IX_InitiativeReports_Code] ON [InitiativeReports] ([Code]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
)
BEGIN
CREATE INDEX [IX_InitiativeReports_InitiativeId] ON [InitiativeReports] ([InitiativeId]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
)
BEGIN
CREATE INDEX [IX_InitiativeReports_Status] ON [InitiativeReports] ([Status]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
)
BEGIN
CREATE UNIQUE INDEX [IX_RecognitionDocuments_Code] ON [RecognitionDocuments] ([Code]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
)
BEGIN
CREATE UNIQUE INDEX [IX_RecognitionDocuments_ReportId_Type] ON [RecognitionDocuments] ([ReportId], [Type]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
)
BEGIN
CREATE INDEX [IX_ReportStatusHistories_ReportId] ON [ReportStatusHistories] ([ReportId]);
END;
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
)
BEGIN
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
VALUES (N'20260416081839_AddReportAndDocumentEntities', N'10.0.6');
END;
COMMIT;
GO
+7
View File
@@ -0,0 +1,7 @@
@echo off
where pwsh >nul 2>&1
if %ERRORLEVEL% == 0 (
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0health-check.ps1" %*
) else (
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0health-check.ps1" %*
)
+53
View File
@@ -0,0 +1,53 @@
# Health check for all running services
$ErrorActionPreference = 'Continue'
$endpoints = @(
@{ Name = '.NET API (DYD.Api)'; Url = 'http://localhost:5443/health'; Critical = $true },
@{ Name = 'Swagger UI'; Url = 'http://localhost:5443/swagger/v1/swagger.json'; Critical = $false },
@{ Name = 'Python AI Service'; Url = 'http://localhost:4402/health'; Critical = $false },
@{ Name = 'Qdrant vector DB'; Url = 'http://localhost:6333/healthz'; Critical = $false },
@{ Name = 'fe0 (User UI)'; Url = 'http://localhost:8080'; Critical = $false },
@{ Name = 'fe-admin (Admin UI)'; Url = 'http://localhost:8082'; Critical = $false },
@{ Name = 'Ollama'; Url = 'http://localhost:11434/api/tags'; Critical = $false }
)
Write-Host ""
Write-Host "Health Check - DYD" -ForegroundColor Cyan
Write-Host ("-" * 70)
$allOk = $true
foreach ($ep in $endpoints) {
try {
$r = Invoke-WebRequest -Uri $ep.Url -UseBasicParsing -TimeoutSec 3 -ErrorAction Stop
if ($r.StatusCode -lt 400) {
$status = '[UP] '
}
else {
$status = '[DEG]'
}
Write-Host ("{0} {1,-30} -> HTTP {2}" -f $status, $ep.Name, $r.StatusCode) -ForegroundColor Green
}
catch {
if ($ep.Critical) {
$color = 'Red'
$marker = '[DOWN]'
}
else {
$color = 'DarkYellow'
$marker = '[OFF] '
}
$msg = $_.Exception.Message
Write-Host ("{0} {1,-30} -> {2}" -f $marker, $ep.Name, $msg) -ForegroundColor $color
if ($ep.Critical) { $allOk = $false }
}
}
Write-Host ("-" * 70)
if ($allOk) {
Write-Host "[OK] All critical services are running." -ForegroundColor Green
exit 0
}
else {
Write-Host "[ERROR] Some critical services are down!" -ForegroundColor Red
exit 1
}
+327
View File
@@ -0,0 +1,327 @@
# -*- coding: utf-8 -*-
"""
Seed 10 medical initiatives across all workflow states.
Each initiative represents a realistic medical/healthcare innovation.
Covers: Draft, Submitted, UnitReview, CouncilReview, Approved (ready for report), Finalized.
"""
import requests
import json
import sys
API = 'https://api.ski-ump.com.vn/api'
ADMIN_EMAIL = 'admin@dhyd.local'
ADMIN_PASSWORD = 'Admin@123456'
def login(email, password):
r = requests.post(f'{API}/auth/login',
json={'email': email, 'password': password},
headers={'Content-Type': 'application/json; charset=utf-8'})
r.raise_for_status()
data = r.json()
return data['accessToken'], data['userId']
def get_units(token):
r = requests.get(f'{API}/admin/units', headers={'Authorization': f'Bearer {token}'})
r.raise_for_status()
return r.json()
def register_user(email, password, full_name, role, admin_token):
"""Register + assign role"""
# Register
r = requests.post(f'{API}/auth/register',
json={'email': email, 'password': password, 'fullName': full_name},
headers={'Authorization': f'Bearer {admin_token}', 'Content-Type': 'application/json; charset=utf-8'})
if r.status_code == 200 or r.status_code == 201 or r.status_code == 204:
data = r.json() if r.text else {}
user_id = data.get('userId') or data.get('id')
if user_id and role != 'Viewer':
# Assign role
r2 = requests.post(f'{API}/admin/users/{user_id}/roles/{role}',
headers={'Authorization': f'Bearer {admin_token}'})
print(f' → Assigned role {role}: {r2.status_code}')
print(f'[OK] Created user {email} ({role})')
return user_id
elif r.status_code == 400 and 'already' in r.text.lower():
print(f'[SKIP] User {email} already exists')
return None
else:
print(f'[FAIL] {email}: {r.status_code} {r.text[:200]}')
return None
def create_initiative(token, data):
r = requests.post(f'{API}/initiatives',
json=data,
headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json; charset=utf-8'})
if r.status_code not in (200, 201):
print(f'[FAIL] Create initiative: {r.status_code} {r.text[:300]}')
return None
result = r.json()
print(f'[OK] Created initiative: {result.get("code","?")} - {data["title"][:50]}')
return result.get('id')
def change_status(token, initiative_id, target_status, comment):
r = requests.post(f'{API}/initiatives/{initiative_id}/status',
json={'targetStatus': target_status, 'comment': comment},
headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json; charset=utf-8'})
if r.status_code not in (200, 204):
print(f'[FAIL] Status change to {target_status}: {r.status_code} {r.text[:200]}')
return False
return True
def submit(token, initiative_id):
r = requests.post(f'{API}/initiatives/{initiative_id}/submit',
json='Nộp tự động từ seed script',
headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json; charset=utf-8'})
return r.status_code in (200, 204)
def approve(token, initiative_id):
r = requests.post(f'{API}/initiatives/{initiative_id}/approve',
json='Phê duyệt từ seed script',
headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json; charset=utf-8'})
return r.status_code in (200, 204)
def reject(token, initiative_id):
r = requests.post(f'{API}/initiatives/{initiative_id}/reject',
json='Từ chối test',
headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json; charset=utf-8'})
return r.status_code in (200, 204)
# Medical initiatives sample data
INITIATIVES = [
{
'title': 'Ứng dụng Trí tuệ nhân tạo trong chẩn đoán hình ảnh X-quang phổi',
'shortSummary': 'Phát triển mô hình AI hỗ trợ bác sĩ chẩn đoán bệnh lý phổi qua ảnh X-quang',
'description': 'Đề xuất xây dựng hệ thống AI dựa trên Deep Learning (CNN) để phân tích ảnh X-quang phổi, phát hiện sớm các bệnh lý như viêm phổi, lao, ung thư phổi. Hệ thống hỗ trợ bác sĩ giảm thời gian chẩn đoán và tăng độ chính xác lên 92%.',
'objectives': 'Xây dựng mô hình AI đạt độ chính xác > 90% cho 5 bệnh lý phổi phổ biến. Triển khai thí điểm tại Bệnh viện Đại học Y Dược.',
'scopeOfApplication': 'Khoa Chẩn đoán hình ảnh, Khoa Nội tổng hợp, Khoa Lao và bệnh phổi',
'expectedOutcomes': 'Giảm 40% thời gian chẩn đoán. Tăng 15% độ chính xác phát hiện sớm. Công bố 2 bài báo khoa học quốc tế.',
'category': 6, # Technology
'group': 1,
'unit_code': 'KY',
'estimatedBudget': 500000000,
'target_status': 3, # Xét cấp trường
},
{
'title': 'Chương trình đào tạo lâm sàng tích hợp thực tại ảo (VR) cho sinh viên Y',
'shortSummary': 'Ứng dụng công nghệ VR trong giảng dạy lâm sàng, mô phỏng ca bệnh và quy trình phẫu thuật',
'description': 'Xây dựng chương trình đào tạo kết hợp VR giúp sinh viên thực hành các kỹ năng lâm sàng trong môi trường ảo an toàn, giảm áp lực lên bệnh viện thực hành, tăng khả năng xử lý tình huống khẩn cấp.',
'objectives': 'Thiết lập 3 phòng lab VR. Xây dựng 20 ca bệnh mô phỏng. Triển khai cho 500 sinh viên Y khoa năm 3-6.',
'scopeOfApplication': 'Khoa Y, sinh viên hệ chính quy và liên thông',
'expectedOutcomes': 'Cải thiện 30% điểm thi lâm sàng. Giảm 50% lỗi kỹ thuật ở sinh viên mới ra trường.',
'category': 1, # Education
'group': 1,
'unit_code': 'PDT',
'estimatedBudget': 2000000000,
'target_status': 4, # Đã duyệt
},
{
'title': 'Phát triển quy trình bào chế thuốc kháng viêm từ dược liệu Việt Nam',
'shortSummary': 'Nghiên cứu chiết xuất hoạt chất kháng viêm từ Nghệ vàng, Ngải cứu, Trà xanh',
'description': 'Phát triển quy trình chuẩn hóa chiết xuất Curcumin, Artemisinin và Catechin theo GMP-WHO để sản xuất thuốc kháng viêm bản địa, thay thế các thuốc nhập khẩu.',
'objectives': 'Xây dựng 3 quy trình chuẩn (curcumin >95%, artemisinin >98%, EGCG >40%). Đăng ký 2 bằng sáng chế.',
'scopeOfApplication': 'Khoa Dược, Công ty Dược phẩm Đại học Y Dược',
'expectedOutcomes': 'Sản xuất 10.000 đơn vị sản phẩm/năm. Giảm 60% chi phí so với thuốc nhập.',
'category': 2, # Research
'group': 2,
'unit_code': 'KD',
'estimatedBudget': 1500000000,
'target_status': 7, # Finalized (đã hoàn tất)
},
{
'title': 'Mô hình chăm sóc bệnh nhân cao tuổi tại nhà (Home Care) với ứng dụng IoT',
'shortSummary': 'Hệ thống theo dõi sức khỏe người già tại nhà qua thiết bị IoT kết nối bác sĩ',
'description': 'Triển khai mô hình chăm sóc tại nhà sử dụng vòng đeo tay, máy đo huyết áp, đường huyết thông minh kết nối với app điều dưỡng. Bác sĩ nhận cảnh báo theo dõi real-time.',
'objectives': 'Phủ 500 hộ gia đình trong 2 năm. Giảm 30% lượt nhập viện cấp cứu ở người > 65 tuổi.',
'scopeOfApplication': 'Khoa Điều Dưỡng, Trung tâm Y tế cộng đồng',
'expectedOutcomes': 'Cứu sống 15-20 ca mỗi năm nhờ phát hiện sớm. Giảm 25% chi phí điều trị.',
'category': 3, # Social Impact
'group': 1,
'unit_code': 'KDD',
'estimatedBudget': 800000000,
'target_status': 2, # Xét cấp khoa
},
{
'title': 'Ứng dụng liệu pháp tế bào gốc trong điều trị viêm khớp mạn tính',
'shortSummary': 'Nghiên cứu lâm sàng sử dụng tế bào gốc trung mô điều trị thoái hóa khớp',
'description': 'Thử nghiệm lâm sàng pha II sử dụng tế bào gốc trung mô (MSC) tách từ mô mỡ tự thân để điều trị thoái hóa khớp gối độ 2-3, tránh phẫu thuật thay khớp nhân tạo.',
'objectives': 'Đạt tỷ lệ cải thiện > 70% sau 6 tháng. Giảm điểm đau VAS từ 7-8 xuống 2-3.',
'scopeOfApplication': 'Khoa Y, Khoa Cơ xương khớp, Bệnh viện ĐHYD',
'expectedOutcomes': 'Công bố 3 bài báo. Ứng dụng rộng sau khi xong pha III.',
'category': 2, # Research
'group': 2,
'unit_code': 'KY',
'estimatedBudget': 3000000000,
'target_status': 3, # Xét cấp trường
},
{
'title': 'Hệ thống số hóa hồ sơ bệnh án nha khoa với chuẩn HL7 FHIR',
'shortSummary': 'Xây dựng hồ sơ bệnh án nha khoa điện tử tích hợp chụp 3D CBCT và X-quang',
'description': 'Chuyển đổi toàn bộ hồ sơ bệnh án giấy sang điện tử, tích hợp ảnh chụp 3D, theo chuẩn HL7 FHIR để trao đổi dữ liệu giữa các bệnh viện.',
'objectives': 'Số hóa 100% hồ sơ trong 18 tháng. Trích xuất được insights từ dữ liệu.',
'scopeOfApplication': 'Khoa Răng Hàm Mặt',
'expectedOutcomes': 'Giảm 70% thời gian tra cứu. Tăng chất lượng chẩn đoán.',
'category': 6, # Technology
'group': 1,
'unit_code': 'KRHM',
'estimatedBudget': 1200000000,
'target_status': 1, # Đã nộp
},
{
'title': 'Nghiên cứu dịch tễ học bệnh đái tháo đường type 2 tại TP.HCM',
'shortSummary': 'Khảo sát tỷ lệ và yếu tố nguy cơ ĐTĐ tại 10 quận huyện TP.HCM',
'description': 'Nghiên cứu cắt ngang trên 5000 người từ 30-70 tuổi tại TP.HCM để xác định tỷ lệ mắc, yếu tố nguy cơ (di truyền, lối sống, béo phì), đề xuất chính sách y tế dự phòng.',
'objectives': 'Có dữ liệu đại diện quần thể TP.HCM. Xây dựng mô hình dự đoán nguy cơ.',
'scopeOfApplication': 'Khoa Y, Sở Y tế TP.HCM',
'expectedOutcomes': 'Xuất bản 5 bài báo ISI. Đề xuất chương trình tầm soát quốc gia.',
'category': 2, # Research
'group': 2,
'unit_code': 'KY',
'estimatedBudget': 2500000000,
'target_status': 0, # Nháp (chưa nộp)
},
{
'title': 'Phương pháp châm cứu điện kết hợp vật lý trị liệu điều trị đau lưng mạn',
'shortSummary': 'Kết hợp y học cổ truyền và hiện đại điều trị đau lưng mạn tính',
'description': 'Phương pháp kết hợp châm cứu điện tại các huyệt Thận du, Đại trường du với bài tập kéo giãn và tăng cường cơ lưng. Nghiên cứu đối chứng ngẫu nhiên 200 bệnh nhân.',
'objectives': 'Giảm ≥50% điểm đau sau 4 tuần. Cải thiện chất lượng cuộc sống (SF-36).',
'scopeOfApplication': 'Khoa Y Học Cổ Truyền, Khoa Phục hồi chức năng',
'expectedOutcomes': 'Xây dựng phác đồ chuẩn. Đào tạo 50 bác sĩ YHCT.',
'category': 2, # Research
'group': 1,
'unit_code': 'KYHCT',
'estimatedBudget': 600000000,
'target_status': 4, # Đã duyệt
},
{
'title': 'Cải tiến quy trình quản lý nghiên cứu khoa học cấp trường qua cổng điện tử',
'shortSummary': 'Xây dựng cổng thông tin quản lý nghiên cứu khoa học từ đề xuất đến nghiệm thu',
'description': 'Phát triển web app cho phép giảng viên nộp đề xuất, theo dõi tiến độ, báo cáo định kỳ, nghiệm thu online. Tích hợp AI kiểm tra trùng lặp đề tài.',
'objectives': 'Số hóa 100% quy trình. Giảm 80% thời gian xử lý giấy tờ.',
'scopeOfApplication': 'Phòng Khoa học Công nghệ, toàn trường',
'expectedOutcomes': 'Tăng 30% số đề tài được phê duyệt. Tiết kiệm 2000 giờ nhân sự/năm.',
'category': 5, # Management
'group': 1,
'unit_code': 'PKHCN',
'estimatedBudget': 400000000,
'target_status': 5, # Từ chối
},
{
'title': 'Chương trình đánh giá năng lực giảng viên theo chuẩn quốc tế (AACSB)',
'shortSummary': 'Xây dựng framework đánh giá giảng viên dựa trên chuẩn AACSB và ABET',
'description': 'Thiết kế hệ thống đánh giá toàn diện năng lực giảng viên: giảng dạy, nghiên cứu, cộng đồng. Tích hợp phần mềm thu thập phản hồi sinh viên, đồng nghiệp.',
'objectives': 'Đánh giá 100% giảng viên trong 3 năm. Công bố Chương trình đạt chuẩn quốc tế.',
'scopeOfApplication': 'Phòng Tổ chức Cán bộ, toàn trường',
'expectedOutcomes': 'Nâng cao chất lượng giảng viên. Đạt chứng nhận kiểm định ngành 2027.',
'category': 5, # Management
'group': 1,
'unit_code': 'PTCCB',
'estimatedBudget': 700000000,
'target_status': 2, # Xét cấp khoa
},
]
def advance_to_status(token, initiative_id, target):
"""Move initiative from Draft (0) step-by-step to target status"""
# State machine: 0 -> 1 -> 2 -> 3 -> 4 ; or 0 -> 1 -> 5 (reject)
if target == 0:
return True # already Draft
if not submit(token, initiative_id):
print(f' [FAIL] submit')
return False
if target == 1:
return True
if not change_status(token, initiative_id, 2, 'Chuyển sang xét cấp khoa'):
return False
if target == 2:
return True
if target == 5:
return reject(token, initiative_id)
if not change_status(token, initiative_id, 3, 'Chuyển sang xét cấp trường'):
return False
if target == 3:
return True
if not approve(token, initiative_id):
return False
if target == 4:
return True
# Move to finalized via report phase (4 -> 6 -> 7)
if target == 7:
if not change_status(token, initiative_id, 6, 'Báo cáo đang duyệt'):
return False
if not change_status(token, initiative_id, 7, 'Hoàn tất'):
return False
return True
def main():
print('=== DYD Sample Data Seeder ===')
print('Logging in as admin...')
admin_token, admin_id = login(ADMIN_EMAIL, ADMIN_PASSWORD)
print(f'Admin: {admin_id[:8]}...')
# Create test users
print('\n=== Creating test users ===')
test_users = [
('reviewer1@dhyd.local', 'Review@2026', 'Trần Thị Bình - Trưởng khoa Y', 'Editor'),
('reviewer2@dhyd.local', 'Review@2026', 'Nguyễn Văn An - Trưởng khoa Dược', 'Editor'),
('gv1@dhyd.local', 'User@123456', 'TS. Lê Hoàng Minh - Giảng viên Khoa Y', 'Viewer'),
('gv2@dhyd.local', 'User@123456', 'ThS. Phạm Thu Hà - Giảng viên Khoa Dược', 'Viewer'),
('gv3@dhyd.local', 'User@123456', 'BS. Võ Thanh Tùng - Giảng viên Khoa Điều Dưỡng', 'Viewer'),
]
for email, pwd, name, role in test_users:
register_user(email, pwd, name, role, admin_token)
# Get units
print('\n=== Loading units ===')
units = get_units(admin_token)
unit_by_code = {u['code']: u for u in units}
for u in units:
print(f' {u["code"]}: {u["name"]}')
# Create + advance each initiative
print(f'\n=== Creating {len(INITIATIVES)} initiatives ===')
for idx, init in enumerate(INITIATIVES, 1):
unit = unit_by_code.get(init['unit_code'])
if not unit:
print(f'[SKIP] Unit {init["unit_code"]} not found')
continue
print(f'\n[{idx}/{len(INITIATIVES)}] {init["title"][:60]}...')
payload = {
'title': init['title'],
'shortSummary': init['shortSummary'],
'description': init['description'],
'objectives': init['objectives'],
'scopeOfApplication': init['scopeOfApplication'],
'expectedOutcomes': init['expectedOutcomes'],
'category': init['category'],
'group': init['group'],
'owningUnitId': unit['id'],
'estimatedBudget': init['estimatedBudget'],
'authors': [{
'authorId': '00000000-0000-0000-0000-000000000000',
'fullName': 'Admin Test',
'email': 'admin@dhyd.local',
'position': 'Quản trị viên',
'academicTitle': 'Tiến sĩ',
'contributionPercentage': 100,
'isLeadAuthor': True,
'contributionDescription': 'Chủ nhiệm đề tài',
}],
}
initiative_id = create_initiative(admin_token, payload)
if initiative_id:
target = init['target_status']
if target != 0:
success = advance_to_status(admin_token, initiative_id, target)
status_name = {0:'Nháp',1:'Đã nộp',2:'Xét cấp khoa',3:'Xét cấp trường',4:'Đã duyệt',5:'Từ chối',6:'Đang báo cáo',7:'Hoàn tất'}[target]
print(f' → Target status: {status_name} ({target}) — {"OK" if success else "FAIL"}')
print('\n✅ Seed complete!')
if __name__ == '__main__':
try:
main()
except Exception as e:
print(f'ERROR: {e}')
sys.exit(1)
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# ============================================================================
# Install a Gitea Actions runner (act_runner) on THIS host for tlam89/sciagent.
# Target: the Gitea + app box 103.149.170.102 (run this ON that box, as root).
#
# Registers ONE runner with two labels so the CI/CD pipeline can use both:
# ci -> docker mode (clean ephemeral containers; backend + frontend jobs)
# deploy -> host mode (runs on the host so it can drive `docker compose`)
#
# Prereqs installed/verified here: Docker + compose plugin, Node.js (needed by
# host-mode actions/checkout). Re-runnable: skips steps already done.
#
# Usage (token already minted for this repo; rotate via Gitea if it expires):
# sudo bash setup-gitea-runner.sh
# Override:
# GITEA_URL=http://localhost:3000 RUNNER_TOKEN=xxx bash setup-gitea-runner.sh
# ============================================================================
set -euo pipefail
GITEA_URL="${GITEA_URL:-http://localhost:3000}"
RUNNER_TOKEN="${RUNNER_TOKEN:-aCd3rpPnJTAPVHqsTIme9SaJm3pUbnbyOQqMrt9O}"
RUNNER_NAME="${RUNNER_NAME:-sciagent-box-runner}"
RUNNER_LABELS="${RUNNER_LABELS:-ci:docker://catthehacker/ubuntu:act-22.04,deploy:host}"
ACT_VERSION="${ACT_VERSION:-0.2.11}"
RUNNER_DIR="${RUNNER_DIR:-/opt/act_runner}"
log(){ printf '\n\033[36m==> %s\033[0m\n' "$*"; }
[[ "$(id -u)" -eq 0 ]] || { echo "Run as root (sudo)."; exit 1; }
# --- 1. Docker + compose plugin --------------------------------------------
if ! command -v docker >/dev/null 2>&1; then
log "Installing Docker (get.docker.com convenience script)"
curl -fsSL https://get.docker.com | sh
fi
if ! docker compose version >/dev/null 2>&1; then
log "docker compose plugin missing — installing docker-compose-plugin"
(apt-get update && apt-get install -y docker-compose-plugin) || \
echo "WARN: install the compose plugin manually for your distro."
fi
systemctl enable --now docker >/dev/null 2>&1 || true
docker version --format '{{.Server.Version}}' >/dev/null 2>&1 || { echo "Docker daemon not running"; exit 1; }
# --- 2. Node.js (host-mode jobs run actions/checkout, which needs node) -----
if ! command -v node >/dev/null 2>&1; then
log "Installing Node.js 20"
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs || \
echo "WARN: install Node.js 20 manually if your distro is non-Debian."
fi
# --- 3. Download act_runner -------------------------------------------------
mkdir -p "$RUNNER_DIR"
cd "$RUNNER_DIR"
if [[ ! -x "$RUNNER_DIR/act_runner" ]]; then
log "Downloading act_runner ${ACT_VERSION}"
arch="$(uname -m)"; case "$arch" in x86_64) arch=amd64;; aarch64) arch=arm64;; esac
curl -fsSL -o act_runner \
"https://dl.gitea.com/act_runner/${ACT_VERSION}/act_runner-${ACT_VERSION}-linux-${arch}"
chmod +x act_runner
fi
# --- 4. Register ------------------------------------------------------------
if [[ ! -f "$RUNNER_DIR/.runner" ]]; then
log "Registering with Gitea ($GITEA_URL) labels: $RUNNER_LABELS"
./act_runner register --no-interactive \
--instance "$GITEA_URL" \
--token "$RUNNER_TOKEN" \
--name "$RUNNER_NAME" \
--labels "$RUNNER_LABELS"
else
log ".runner already exists — skipping register"
fi
[[ -f "$RUNNER_DIR/config.yaml" ]] || ./act_runner generate-config > config.yaml
# generate-config injects DEFAULT labels (ubuntu-latest/22.04/20.04) into config.yaml
# which OVERRIDE the --labels passed at registration. Force our labels into config.yaml
# so the daemon advertises ci (docker) + deploy (host), not the defaults.
python3 - "$RUNNER_DIR/config.yaml" "$RUNNER_LABELS" <<'PY'
import sys,re
path,labels=sys.argv[1],sys.argv[2].split(",")
s=open(path).read().splitlines(keepends=True)
o=[];i=0;n=len(s);done=False
while i<n:
if not done and re.match(r'^\s{2}labels:\s*$',s[i]):
o.append(" labels:\n")
for x in labels: o.append(' - "%s"\n'%x)
i+=1
while i<n and re.match(r'^\s{4}(- |#)',s[i]): i+=1
done=True; continue
o.append(s[i]); i+=1
if not done:
o2=[];ins=False
for line in o:
o2.append(line)
if not ins and re.match(r'^runner:\s*$',line):
o2.append(" labels:\n")
for x in labels: o2.append(' - "%s"\n'%x)
ins=True
o=o2
open(path,"w").write("".join(o))
print("config.yaml labels ->", ", ".join(labels))
PY
# --- 5. systemd service -----------------------------------------------------
log "Installing systemd service act_runner"
cat > /etc/systemd/system/act_runner.service <<UNIT
[Unit]
Description=Gitea Actions runner (sciagent)
After=docker.service network-online.target
Wants=docker.service
[Service]
WorkingDirectory=${RUNNER_DIR}
ExecStart=${RUNNER_DIR}/act_runner daemon --config ${RUNNER_DIR}/config.yaml
Restart=always
RestartSec=5
User=root
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now act_runner
sleep 3
systemctl --no-pager --full status act_runner | head -12
echo
echo "Done. Verify in Gitea: Repo → Settings → Actions → Runners — '$RUNNER_NAME' should be Online."
echo "Logs: journalctl -u act_runner -f"
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""
Split file gốc "bao-cao-template-original.docx" thành 5 template riêng biệt:
- mau-01-bao-cao-mo-ta.docx (includes 2 cover pages)
- mau-02-don-de-nghi.docx
- mau-03-xac-nhan-ty-le.docx
- mau-04-phieu-danh-gia.docx
- ban-cam-ket.docx
Strategy: unzip original → parse document.xml → state-machine iterate body children
(p, tbl, ...), group by "Mẫu số 0X" markers → write 5 output docx bằng cách clone
unpacked directory và thay document.xml body children cho mỗi template.
"""
import shutil
import sys
import tempfile
import xml.etree.ElementTree as ET
import zipfile
from pathlib import Path
NS_URI = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
W = f'{{{NS_URI}}}'
ET.register_namespace('w', NS_URI)
# Also register common namespaces in docx
EXTRA_NS = {
'wpc': 'http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas',
'mc': 'http://schemas.openxmlformats.org/markup-compatibility/2006',
'o': 'urn:schemas-microsoft-com:office:office',
'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
'm': 'http://schemas.openxmlformats.org/officeDocument/2006/math',
'v': 'urn:schemas-microsoft-com:vml',
'wp14': 'http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing',
'wp': 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing',
'w10': 'urn:schemas-microsoft-com:office:word',
'w14': 'http://schemas.microsoft.com/office/word/2010/wordml',
'w15': 'http://schemas.microsoft.com/office/word/2012/wordml',
'wpg': 'http://schemas.microsoft.com/office/word/2010/wordprocessingGroup',
'wpi': 'http://schemas.microsoft.com/office/word/2010/wordprocessingInk',
'wne': 'http://schemas.microsoft.com/office/word/2006/wordml',
'wps': 'http://schemas.microsoft.com/office/word/2010/wordprocessingShape',
}
for prefix, uri in EXTRA_NS.items():
ET.register_namespace(prefix, uri)
def get_para_text(p):
return ''.join((t.text or '') for t in p.iter(f'{W}t')).strip()
import re
HEADER_PREFIXES = (
'BỘ Y TẾ',
'ĐẠI HỌC Y DƯỢC',
'THÀNH PHỐ HỒ CHÍ MINH',
'ĐƠN VỊ:',
'ĐƠN VỊ ',
'CỘNG HÒA XÃ HỘI',
'CÔNG HÒA XÃ HỘI',
'CỘNG HOÀ XÃ HỘI',
'CỘNG HOÀ XÃ HỘI CHỦ NGHĨA',
'Độc lập',
'TP. Hồ Chí Minh',
'Tp. Hồ Chí Minh',
'Thành phố Hồ Chí Minh',
)
def is_header_like(text: str) -> bool:
if not text:
return False
for p in HEADER_PREFIXES:
if text.startswith(p):
return True
return bool(re.match(r'^Mẫu\s*số\s*\d', text))
def find_section_markers(children):
"""
Scan children, tìm marker paragraphs "Mẫu số 0X" / "BẢN CAM KẾT" + walk back
để include header block (BỘ Y TẾ / ĐẠI HỌC Y DƯỢC / ...). Return dict
section_name → start_index.
"""
section_starts = {'mau01': 0} # mau01 bao gồm cover + content
for i, child in enumerate(children):
tag = child.tag.replace(W, '')
if tag != 'p':
continue
text = get_para_text(child)
if not text:
continue
section_key = None
if 'BẢN CAM KẾT' in text.upper():
section_key = 'camket'
else:
m = re.match(r'^Mẫu\s*số\s*(0[234])', text)
if m:
section_key = f'mau{m.group(1)}'
if section_key is None or section_key in section_starts:
continue
# Walk back: include paragraphs + tables chứa header text
start = i
for j in range(i - 1, -1, -1):
prev = children[j]
tag = prev.tag.replace(W, '')
ptext = get_para_text(prev).strip()
if tag == 'p':
if ptext == '' or is_header_like(ptext):
start = j
else:
break
elif tag == 'tbl':
# Header table bắt buộc có "BỘ Y TẾ" hoặc "ĐẠI HỌC Y DƯỢC" hoặc
# "CỘNG HÒA/HOÀ XÃ HỘI" (strong markers). Tránh nhầm với signature
# tbl của mẫu trước (chỉ có "Tp. Hồ Chí Minh, ngày...").
strong_markers = (
'BỘ Y TẾ',
'ĐẠI HỌC Y DƯỢC',
'CỘNG HÒA XÃ HỘI',
'CỘNG HOÀ XÃ HỘI',
'CÔNG HÒA XÃ HỘI',
)
if any(mk in ptext for mk in strong_markers):
start = j
else:
break
else:
break
section_starts[section_key] = start
return section_starts
def split_document(original_path: Path, output_dir: Path):
"""Split original docx into 5 templates."""
with tempfile.TemporaryDirectory() as tmp_str:
tmp = Path(tmp_str)
# Extract original
base_dir = tmp / 'base'
base_dir.mkdir()
with zipfile.ZipFile(original_path, 'r') as z:
z.extractall(base_dir)
# Parse document.xml
doc_xml_path = base_dir / 'word' / 'document.xml'
tree = ET.parse(doc_xml_path)
root = tree.getroot()
body = root.find(f'{W}body')
assert body is not None, 'No body element'
# Collect sectPr (preserve for each output)
sect_pr = None
children = list(body)
content_children = [] # children excluding sectPr
for child in children:
if child.tag.replace(W, '') == 'sectPr':
sect_pr = child
else:
content_children.append(child)
# Find section start indices (với walk-back include header block)
starts = find_section_markers(content_children)
# Build ordered list: mau01 at 0, then other sections sorted by start index
ordered = sorted(starts.items(), key=lambda kv: kv[1])
# Build sections dict — assign children [start_i, start_{i+1}) to each section
sections = {k: [] for k in ['mau01', 'mau02', 'mau03', 'mau04', 'camket']}
for idx, (sec_name, start) in enumerate(ordered):
end = ordered[idx + 1][1] if idx + 1 < len(ordered) else len(content_children)
sections[sec_name] = content_children[start:end]
# Write 5 output files
output_dir.mkdir(parents=True, exist_ok=True)
outputs = [
('mau-01-bao-cao-mo-ta.docx', 'mau01'),
('mau-02-don-de-nghi.docx', 'mau02'),
('mau-03-xac-nhan-ty-le.docx', 'mau03'),
('mau-04-phieu-danh-gia.docx', 'mau04'),
('ban-cam-ket.docx', 'camket'),
]
for filename, section_key in outputs:
out_path = output_dir / filename
# Clone base directory
clone_dir = tmp / f'clone-{section_key}'
shutil.copytree(base_dir, clone_dir)
# Modify document.xml
clone_doc = clone_dir / 'word' / 'document.xml'
ctree = ET.parse(clone_doc)
croot = ctree.getroot()
cbody = croot.find(f'{W}body')
assert cbody is not None
# Clear existing body children
for c in list(cbody):
cbody.remove(c)
# Add section-specific children
for elem in sections[section_key]:
cbody.append(elem)
# Add sectPr last
if sect_pr is not None:
cbody.append(sect_pr)
ctree.write(clone_doc, encoding='UTF-8', xml_declaration=True)
# Rezip
if out_path.exists():
out_path.unlink()
with zipfile.ZipFile(out_path, 'w', zipfile.ZIP_DEFLATED) as zout:
for file_path in clone_dir.rglob('*'):
if file_path.is_file():
zout.write(file_path, file_path.relative_to(clone_dir))
count = len(sections[section_key])
print(f'Wrote {out_path.name} ({count} body children)')
def main():
repo_root = Path(__file__).parent.parent
original = repo_root / 'src/Backend/DYD.Api/Templates/bao-cao-template-original.docx'
output_dir = repo_root / 'src/Backend/DYD.Api/Templates'
if not original.exists():
print(f'ERROR: source template not found: {original}')
sys.exit(1)
split_document(original, output_dir)
print('\nDone. 5 template files created.')
if __name__ == '__main__':
main()
+22
View File
@@ -0,0 +1,22 @@
@echo off
REM Khởi động cả 2 frontend (fe0 + fe-admin) trong 2 cửa sổ riêng
REM Yêu cầu: Node.js 20+ đã cài
setlocal
set ROOT=%~dp0..
echo [1/2] Khởi động fe0 (User frontend) - http://localhost:8080
start "fe0 - User Frontend" cmd /k "cd /d %ROOT%\fe0 && (if not exist node_modules npm install) && npm run dev"
timeout /t 2 /nobreak >nul
echo [2/2] Khởi động fe-admin (Admin frontend) - http://localhost:8082
start "fe-admin - Admin Frontend" cmd /k "cd /d %ROOT%\fe-admin && (if not exist node_modules npm install) && npm run dev"
echo.
echo Da khoi dong 2 frontend trong 2 cua so rieng.
echo - User UI: http://localhost:8080
echo - Admin UI: http://localhost:8082
echo.
echo Backend .NET (DYD.Api) chay rieng tu Visual Studio (F5).
endlocal
+24
View File
@@ -0,0 +1,24 @@
# Khởi động cả 2 frontend (fe0 + fe-admin) trong 2 PowerShell window riêng
# Yêu cầu: Node.js 20+ đã cài
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
function Start-Frontend {
param([string]$Name, [string]$Path, [int]$Port)
Write-Host "[$Name] Khởi động trên port $Port..." -ForegroundColor Cyan
$cmd = "Set-Location '$Path'; if (-not (Test-Path node_modules)) { npm install }; npm run dev"
Start-Process pwsh -ArgumentList '-NoExit', '-Command', $cmd -WindowStyle Normal
}
Start-Frontend -Name 'fe0 (User)' -Path "$root\fe0" -Port 8080
Start-Sleep -Seconds 2
Start-Frontend -Name 'fe-admin (Admin)' -Path "$root\fe-admin" -Port 8082
Write-Host ""
Write-Host "✅ Đã khởi động 2 frontend:" -ForegroundColor Green
Write-Host " - User UI: http://localhost:8080"
Write-Host " - Admin UI: http://localhost:8082"
Write-Host ""
Write-Host "Backend .NET (DYD.Api) chạy riêng từ Visual Studio (F5)." -ForegroundColor Yellow
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Fix B: set the Postgres role password to match POSTGRES_PASSWORD in .env (no volume wipe).
#
# Works when `psql` inside the container can connect to the server over the Unix socket
# without a password (common for the official postgres images local/trust rules). The app
# role (POSTGRES_USER) can change its own password, or you can set POSTGRES_SUPERUSER to
# connect as a different superuser (e.g. postgres) when that role still exists.
#
# Usage (repo root, stack can be up):
# ./scripts/sync-postgres-app-password.sh
#
# Optional .env entries:
# POSTGRES_SUPERUSER=postgres # DB role to connect as (default: same as POSTGRES_USER)
#
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE=(docker compose --env-file "${ROOT}/.env" -f "${ROOT}/docker-compose.prod.yml")
cd "$ROOT"
ENV_FILE="${ENV_FILE:-$ROOT/.env}"
if [[ ! -f "$ENV_FILE" ]]; then
printf 'Missing %s\n' "$ENV_FILE" >&2
exit 1
fi
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%$'\r'}"
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
key="${BASH_REMATCH[1]}"
val="${BASH_REMATCH[2]}"
val="${val#"${val%%[![:space:]]*}"}"
val="${val%"${val##*[![:space:]]}"}"
export "${key}=${val}"
fi
done <"$ENV_FILE"
if [[ -z "${POSTGRES_USER:-}" || -z "${POSTGRES_PASSWORD:-}" ]]; then
printf 'POSTGRES_USER and POSTGRES_PASSWORD must be set in %s\n' "$ENV_FILE" >&2
exit 1
fi
# SQL string literal: escape single quotes per PostgreSQL rules (' -> '')
sql_escape_literal() {
printf '%s' "$1" | sed "s/'/''/g"
}
# Double-quoted identifier: " -> "
sql_escape_ident() {
printf '%s' "$1" | sed 's/"/""/g'
}
conn_role="${POSTGRES_SUPERUSER:-$POSTGRES_USER}"
pass_esc="$(sql_escape_literal "$POSTGRES_PASSWORD")"
user_ident="$(sql_escape_ident "$POSTGRES_USER")"
sql="ALTER ROLE \"${user_ident}\" WITH PASSWORD '${pass_esc}';"
printf '→ Connecting as Postgres role %q (inside container), updating password for role %q…\n' "$conn_role" "$POSTGRES_USER"
if "${COMPOSE[@]}" exec -T postgres psql -U "$conn_role" -d postgres -v ON_ERROR_STOP=1 -c "$sql"; then
printf 'OK — role %q password now matches POSTGRES_PASSWORD in .env.\nRestart be0 if it was crash-looping:\n %s restart be0\n' "$POSTGRES_USER" "${COMPOSE[*]}"
exit 0
fi
printf '\nThat failed. Common causes:\n' >&2
printf ' • This cluster has no local "trust" for psql — supply the current password and run manually:\n' >&2
printf ' PGPASSWORD="(old)" docker compose --env-file .env -f docker-compose.prod.yml exec -T postgres \\\n' >&2
printf ' psql -U %q -d postgres -c "ALTER ROLE ..."\n' "$POSTGRES_USER" >&2
printf ' • Role %q is not a superuser and you connected as the wrong POSTGRES_SUPERUSER — set POSTGRES_SUPERUSER in .env to a superuser that exists.\n' "$POSTGRES_USER" >&2
printf ' • See docs/deploy-production-docker.md §3\n' >&2
exit 1
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Smoke-test verify-prod-env.sh rejects insecure values.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
cat >"$TMP" <<'EOF'
PUBLIC_HOST=example.com
FE_PORT=8081
MINIO_API_PORT=19000
MINIO_CONSOLE_PORT=19001
MINIO_ROOT_USER=minio_user
MINIO_ROOT_PASSWORD=long_random_minio_secret_value
POSTGRES_USER=initiative
POSTGRES_PASSWORD=long_random_postgres_secret
POSTGRES_DB=initiatives
JWT_SECRET=short
MINIO_API_CORS_ALLOW_ORIGIN=*
EOF
if ENV_FILE="$TMP" "$ROOT/scripts/verify-prod-env.sh" >/dev/null 2>&1; then
echo "FAIL: verify-prod-env.sh should reject short JWT_SECRET and CORS *" >&2
exit 1
fi
cat >"$TMP" <<'EOF'
PUBLIC_HOST=example.com
FE_PORT=8081
MINIO_API_PORT=19000
MINIO_CONSOLE_PORT=19001
MINIO_ROOT_USER=minio_user
MINIO_ROOT_PASSWORD=long_random_minio_secret_value
POSTGRES_USER=initiative
POSTGRES_PASSWORD=long_random_postgres_secret
POSTGRES_DB=initiatives
JWT_SECRET=this-is-a-valid-production-jwt-secret-32chars-min
MINIO_API_CORS_ALLOW_ORIGIN=https://example.com
EOF
ENV_FILE="$TMP" "$ROOT/scripts/verify-prod-env.sh" >/dev/null
echo "OK — verify-prod-env.sh accepts secure sample .env"
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Validates variables required by docker-compose.prod.yml before compose runs.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
ENV_FILE="${ENV_FILE:-$ROOT/.env}"
if [[ ! -f "$ENV_FILE" ]]; then
printf 'Missing %s\nCopy .env.example to .env and fill in secrets.\n' "$ENV_FILE" >&2
exit 1
fi
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%$'\r'}"
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
key="${BASH_REMATCH[1]}"
val="${BASH_REMATCH[2]}"
val="${val#"${val%%[![:space:]]*}"}"
val="${val%"${val##*[![:space:]]}"}"
export "${key}=${val}"
fi
done <"$ENV_FILE"
reject_leading_space_after_equals() {
local key rhs
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%$'\r'}"
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
if [[ "$line" =~ ^[[:space:]]*(POSTGRES_USER|POSTGRES_PASSWORD|POSTGRES_DB|MINIO_ROOT_USER|MINIO_ROOT_PASSWORD|MINIO_API_PORT|MINIO_CONSOLE_PORT|FE_PORT|PUBLIC_HOST)=(.*)$ ]]; then
key="${BASH_REMATCH[1]}"
rhs="${BASH_REMATCH[2]}"
[[ "$rhs" =~ ^[[:space:]]+ ]] || continue
printf 'Invalid format in %s: "%s" has a space (or tab) immediately after "=".\n' "$ENV_FILE" "$key" >&2
printf 'Use KEY=value without a space after the equals sign.\n' >&2
exit 1
fi
done <"$ENV_FILE"
}
reject_leading_space_after_equals
MISSING=()
need() {
local n="$1"
local v="${!n-}"
if [[ -z "${v//[:space:]}" ]]; then
MISSING+=("$n")
fi
}
need PUBLIC_HOST
need FE_PORT
need MINIO_API_PORT
need MINIO_CONSOLE_PORT
need MINIO_ROOT_USER
need MINIO_ROOT_PASSWORD
need POSTGRES_USER
need POSTGRES_PASSWORD
need POSTGRES_DB
need JWT_SECRET
need MINIO_API_CORS_ALLOW_ORIGIN
if [[ ${#MISSING[@]} -gt 0 ]]; then
printf 'Unset or whitespace-only variables in %s:\n %s\n' "$ENV_FILE" "${MISSING[*]}" >&2
printf '(Blank values break MinIO: MINIO_BROWSER_REDIRECT_URL would have an empty host.)\n' >&2
exit 1
fi
if ! [[ "${MINIO_CONSOLE_PORT}" =~ ^[0-9]+$ ]] || ! [[ "${MINIO_API_PORT}" =~ ^[0-9]+$ ]] || ! [[ "${FE_PORT}" =~ ^[0-9]+$ ]]; then
printf 'Ports must be numeric (FE_PORT, MINIO_*_PORT).\n' >&2
exit 1
fi
# Postgres role/database names in URLs/scripts: avoid quoting and “role does not exist” churn.
if ! [[ "${POSTGRES_USER}" =~ ^[a-zA-Z_][a-zA-Z0-9_]{0,62}$ ]]; then
printf 'POSTGRES_USER must be an unquoted SQL identifier (e.g. initiative): letters, digits, underscore; max 63 chars.\n' >&2
printf 'Avoid special characters like "!". Docker only creates this role when the Postgres data directory is EMPTY.\n' >&2
printf 'Full guide: docs/deploy-production-docker.md (Postgres).\n' >&2
exit 1
fi
if ! [[ "${POSTGRES_DB}" =~ ^[a-zA-Z_][a-zA-Z0-9_]{0,62}$ ]]; then
printf 'POSTGRES_DB must be an unquoted SQL identifier (e.g. initiatives): letters, digits, underscore; max 63 chars.\n' >&2
printf 'INITIATIVE_DATABASE_URL is built without escaping; exotic names break URLs.\n' >&2
exit 1
fi
# INITIATIVE_DATABASE_URL is assembled without encoding; :, @, / break the URL if unescaped.
if [[ "${POSTGRES_PASSWORD}" == *"@"* ]] || [[ "${POSTGRES_PASSWORD}" == *":"* ]] || [[ "${POSTGRES_PASSWORD}" == *"/"* ]] || [[ "${POSTGRES_PASSWORD}" == *"%"* ]]; then
printf 'POSTGRES_PASSWORD contains @, :, / or %%. URL-unsafe for INITIATIVE_DATABASE_URL; use alphanumeric + punctuation like + or = from openssl rand -base64.\n' >&2
exit 1
fi
if [[ "${MINIO_API_CORS_ALLOW_ORIGIN}" == "*" ]]; then
printf 'MINIO_API_CORS_ALLOW_ORIGIN must not be * in production — use your SPA origin (e.g. https://www.rcc-ump.com).\n' >&2
exit 1
fi
if [[ ${#JWT_SECRET} -lt 32 ]]; then
printf 'JWT_SECRET must be at least 32 characters (generate: openssl rand -base64 48).\n' >&2
exit 1
fi
preview="http://${PUBLIC_HOST}:${MINIO_CONSOLE_PORT}"
echo "OK — required vars set; MINIO_BROWSER_REDIRECT_URL preview: ${preview}"
echo " CORS for public UI: http://${PUBLIC_HOST}:${FE_PORT} (+ localhost defaults in be0; optional CORS_ORIGINS_EXTRA in .env)"
if [[ -n "${S3_PUBLIC_ENDPOINT_URL:-}" && "${S3_PUBLIC_ENDPOINT_URL}" == https:* ]]; then
echo " HTTPS MinIO (S3_PUBLIC_ENDPOINT_URL): ensure TLS proxy → 127.0.0.1:${MINIO_API_PORT}; see docs/minio-behind-https.md"
fi
if [[ -n "${MINIO_SERVER_URL:-}" && "${MINIO_SERVER_URL}" == https:* && -z "${S3_PUBLIC_ENDPOINT_URL:-}" ]]; then
echo " Warn: MINIO_SERVER_URL is https but S3_PUBLIC_ENDPOINT_URL is unset — be0 defaults may still presign HTTP. Set both to match."
fi