462 lines
16 KiB
Bash
462 lines
16 KiB
Bash
#!/bin/bash
|
||
set -e
|
||
|
||
# --- НАСТРОЙКИ (ПРОВЕРЬТЕ ПЕРЕД ЗАПУСКОМ) ---
|
||
WEB_HOST="192.168.254.36"
|
||
DB_HOST="192.168.254.35"
|
||
DB_PORT="5432"
|
||
DB_NAME="asr_db"
|
||
DB_USER="asr_user"
|
||
DB_PASSWORD="poluyan77"
|
||
ASR_HOST="192.168.254.34"
|
||
ASR_AUDIO_PORT="8080"
|
||
ASR_CMD_PORT="8003"
|
||
ADMIN_PASSWORD="admin123"
|
||
# --- КОНЕЦ НАСТРОЕК ---
|
||
|
||
echo "=== 1. Установка базовых пакетов ==="
|
||
apt update
|
||
apt install -y python3 python3-pip python3-venv nginx curl git
|
||
|
||
echo "=== 2. Установка Node.js для сборки фронтенда ==="
|
||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||
apt install -y nodejs
|
||
|
||
echo "=== 3. Создание проекта и виртуального окружения ==="
|
||
mkdir -p /opt/asr-web
|
||
cd /opt/asr-web
|
||
python3 -m venv venv
|
||
source venv/bin/activate
|
||
|
||
echo "=== 4. Установка Python-пакетов ==="
|
||
pip install fastapi uvicorn psycopg2-binary python-jose[cryptography] passlib[bcrypt] python-multipart bcrypt requests
|
||
|
||
echo "=== 5. Создание API сервера (main.py) с переменными окружения ==="
|
||
cat > /opt/asr-web/main.py << 'EOF'
|
||
import os
|
||
from fastapi import FastAPI, Depends, HTTPException, status
|
||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||
from pydantic import BaseModel
|
||
from typing import List, Optional
|
||
from datetime import datetime, timedelta
|
||
import psycopg2
|
||
from psycopg2.extras import RealDictCursor
|
||
from jose import JWTError, jwt
|
||
import bcrypt
|
||
import requests
|
||
|
||
# ========== НАСТРОЙКИ ИЗ ПЕРЕМЕННЫХ ОКРУЖЕНИЯ ==========
|
||
DB_HOST = os.getenv("DB_HOST", "192.168.254.35")
|
||
DB_PORT = os.getenv("DB_PORT", "5432")
|
||
DB_NAME = os.getenv("DB_NAME", "asr_db")
|
||
DB_USER = os.getenv("DB_USER", "asr_user")
|
||
DB_PASSWORD = os.getenv("DB_PASSWORD", "poluyan77")
|
||
ASR_HOST = os.getenv("ASR_HOST", "192.168.254.34")
|
||
ASR_AUDIO_PORT = os.getenv("ASR_AUDIO_PORT", "8080")
|
||
ASR_CMD_PORT = os.getenv("ASR_CMD_PORT", "8003")
|
||
|
||
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-this-in-production-12345")
|
||
ALGORITHM = "HS256"
|
||
ACCESS_TOKEN_EXPIRE_MINUTES = 480
|
||
|
||
DB_CONFIG = {
|
||
"host": DB_HOST,
|
||
"port": DB_PORT,
|
||
"database": DB_NAME,
|
||
"user": DB_USER,
|
||
"password": DB_PASSWORD
|
||
}
|
||
|
||
security = HTTPBearer()
|
||
app = FastAPI(title="ASR API", version="1.0")
|
||
|
||
# ========== МОДЕЛИ ==========
|
||
class UserLogin(BaseModel):
|
||
username: str
|
||
password: str
|
||
|
||
class Token(BaseModel):
|
||
access_token: str
|
||
token_type: str
|
||
|
||
class CallResponse(BaseModel):
|
||
id: int
|
||
audio_filename: str
|
||
incoming_number: Optional[str] = None
|
||
outgoing_number: Optional[str] = None
|
||
call_date: Optional[str] = None
|
||
call_time: Optional[str] = None
|
||
operator_name: Optional[str] = None
|
||
status: str
|
||
duration: Optional[float] = None
|
||
|
||
class TagInput(BaseModel):
|
||
tag_name: str
|
||
|
||
class OperatorNameInput(BaseModel):
|
||
name: str
|
||
|
||
class CommentInput(BaseModel):
|
||
comment: str
|
||
|
||
# ========== ФУНКЦИИ ==========
|
||
def get_db():
|
||
conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
|
||
try:
|
||
yield conn
|
||
finally:
|
||
conn.close()
|
||
|
||
def verify_password(plain_password, hashed_password):
|
||
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
|
||
|
||
def authenticate_user(username: str, password: str, conn):
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT id, username, password_hash, role FROM users WHERE username = %s", (username,))
|
||
user = cursor.fetchone()
|
||
if not user:
|
||
return None
|
||
if not verify_password(password, user['password_hash']):
|
||
return None
|
||
return user
|
||
|
||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||
to_encode = data.copy()
|
||
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
|
||
to_encode.update({"exp": expire})
|
||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||
|
||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security), conn = Depends(get_db)):
|
||
token = credentials.credentials
|
||
try:
|
||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||
username: str = payload.get("sub")
|
||
if username is None:
|
||
raise HTTPException(status_code=401, detail="Invalid token")
|
||
except JWTError:
|
||
raise HTTPException(status_code=401, detail="Invalid token")
|
||
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT id, username, role FROM users WHERE username = %s", (username,))
|
||
user = cursor.fetchone()
|
||
if user is None:
|
||
raise HTTPException(status_code=401, detail="User not found")
|
||
return user
|
||
|
||
# ========== ЭНДПОИНТЫ ==========
|
||
@app.post("/api/token", response_model=Token)
|
||
async def login(form: UserLogin, conn = Depends(get_db)):
|
||
user = authenticate_user(form.username, form.password, conn)
|
||
if not user:
|
||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||
access_token = create_access_token(data={"sub": user['username']})
|
||
return {"access_token": access_token, "token_type": "bearer"}
|
||
|
||
@app.get("/api/calls", response_model=List[CallResponse])
|
||
async def get_calls(
|
||
skip: int = 0,
|
||
limit: int = 50,
|
||
search: Optional[str] = None,
|
||
conn = Depends(get_db),
|
||
current_user = Depends(get_current_user)
|
||
):
|
||
cursor = conn.cursor()
|
||
|
||
if search:
|
||
cursor.execute("""
|
||
SELECT DISTINCT c.id, c.audio_filename, c.incoming_number, c.outgoing_number,
|
||
c.call_date, c.call_time, c.operator_name, c.status, c.audio_duration_sec as duration
|
||
FROM calls c
|
||
JOIN transcriptions t ON t.call_id = c.id
|
||
JOIN transcription_segments s ON s.transcript_id = t.id
|
||
WHERE to_tsvector('russian', s.text) @@ plainto_tsquery('russian', %s)
|
||
ORDER BY c.call_date DESC
|
||
LIMIT %s OFFSET %s
|
||
""", (search, limit, skip))
|
||
else:
|
||
cursor.execute("""
|
||
SELECT id, audio_filename, incoming_number, outgoing_number, call_date, call_time,
|
||
operator_name, status, audio_duration_sec as duration
|
||
FROM calls
|
||
ORDER BY call_date DESC
|
||
LIMIT %s OFFSET %s
|
||
""", (limit, skip))
|
||
|
||
rows = cursor.fetchall()
|
||
result = []
|
||
for row in rows:
|
||
item = dict(row)
|
||
if item.get('call_date'):
|
||
item['call_date'] = str(item['call_date'])
|
||
if item.get('call_time'):
|
||
item['call_time'] = str(item['call_time'])
|
||
result.append(item)
|
||
return result
|
||
|
||
@app.get("/api/calls/{call_id}/transcription")
|
||
async def get_transcription(call_id: int, conn = Depends(get_db), current_user = Depends(get_current_user)):
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
SELECT s.start_time_sec, s.end_time_sec, s.speaker, s.text
|
||
FROM transcription_segments s
|
||
JOIN transcriptions t ON t.id = s.transcript_id
|
||
WHERE t.call_id = %s
|
||
ORDER BY s.start_time_sec
|
||
""", (call_id,))
|
||
segments = cursor.fetchall()
|
||
|
||
cursor.execute("SELECT full_text FROM transcriptions WHERE call_id = %s", (call_id,))
|
||
full_text = cursor.fetchone()
|
||
|
||
return {
|
||
"call_id": call_id,
|
||
"full_text": full_text['full_text'] if full_text else "",
|
||
"segments": [dict(seg) for seg in segments]
|
||
}
|
||
|
||
@app.get("/api/calls/{call_id}/audio")
|
||
async def get_audio_url(call_id: int, conn = Depends(get_db), current_user = Depends(get_current_user)):
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT audio_filename FROM calls WHERE id = %s", (call_id,))
|
||
row = cursor.fetchone()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="Call not found")
|
||
|
||
filename = row['audio_filename'].replace('.wav', '.flac')
|
||
audio_url = f"http://{ASR_HOST}:{ASR_AUDIO_PORT}/audio/{filename}"
|
||
return {"audio_url": audio_url}
|
||
|
||
@app.post("/api/calls/{call_id}/tags")
|
||
async def add_tag(call_id: int, tag: TagInput, conn = Depends(get_db), current_user = Depends(get_current_user)):
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT 1 FROM auto_tags WHERE call_id = %s AND tag_name = %s", (call_id, tag.tag_name))
|
||
if cursor.fetchone():
|
||
raise HTTPException(status_code=400, detail="Tag already exists")
|
||
|
||
cursor.execute("INSERT INTO auto_tags (call_id, tag_name, confidence) VALUES (%s, %s, 1.0)", (call_id, tag.tag_name))
|
||
conn.commit()
|
||
return {"message": "Tag added", "tag": tag.tag_name}
|
||
|
||
@app.delete("/api/calls/{call_id}/tags/{tag_name}")
|
||
async def delete_tag(call_id: int, tag_name: str, conn = Depends(get_db), current_user = Depends(get_current_user)):
|
||
cursor = conn.cursor()
|
||
cursor.execute("DELETE FROM auto_tags WHERE call_id = %s AND tag_name = %s AND tag_name NOT LIKE 'эмоция_%'", (call_id, tag_name))
|
||
conn.commit()
|
||
if cursor.rowcount == 0:
|
||
raise HTTPException(status_code=404, detail="Tag not found")
|
||
return {"status": "ok", "deleted": tag_name}
|
||
|
||
@app.get("/api/calls/{call_id}/tags")
|
||
async def get_tags(call_id: int, conn = Depends(get_db), current_user = Depends(get_current_user)):
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT tag_name, confidence, created_at FROM auto_tags WHERE call_id = %s ORDER BY created_at DESC", (call_id,))
|
||
tags = cursor.fetchall()
|
||
return {"tags": [dict(tag) for tag in tags]}
|
||
|
||
@app.get("/api/calls/{call_id}/comment")
|
||
async def get_comment(call_id: int, conn = Depends(get_db), current_user = Depends(get_current_user)):
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT quality_comment FROM calls WHERE id = %s", (call_id,))
|
||
row = cursor.fetchone()
|
||
return {"comment": row['quality_comment'] if row else ""}
|
||
|
||
@app.post("/api/calls/{call_id}/comment")
|
||
async def save_comment(call_id: int, data: CommentInput, conn = Depends(get_db), current_user = Depends(get_current_user)):
|
||
cursor = conn.cursor()
|
||
cursor.execute("UPDATE calls SET quality_comment = %s WHERE id = %s", (data.comment, call_id))
|
||
conn.commit()
|
||
return {"status": "ok"}
|
||
|
||
# ========== УПРАВЛЕНИЕ ИМЕНАМИ ОПЕРАТОРОВ ==========
|
||
@app.get("/api/operator-names")
|
||
async def get_operator_names(conn = Depends(get_db), current_user = Depends(get_current_user)):
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT id, name FROM operator_names ORDER BY name")
|
||
rows = cursor.fetchall()
|
||
return [{"id": row['id'], "name": row['name']} for row in rows]
|
||
|
||
@app.post("/api/operator-names")
|
||
async def add_operator_name(data: OperatorNameInput, conn = Depends(get_db), current_user = Depends(get_current_user)):
|
||
if current_user['role'] != 'admin':
|
||
raise HTTPException(status_code=403, detail="Только для администратора")
|
||
cursor = conn.cursor()
|
||
try:
|
||
cursor.execute("INSERT INTO operator_names (name) VALUES (%s) RETURNING id", (data.name,))
|
||
result = cursor.fetchone()
|
||
conn.commit()
|
||
return {"id": result[0], "name": data.name}
|
||
except Exception:
|
||
conn.rollback()
|
||
raise HTTPException(status_code=400, detail="Имя уже существует")
|
||
|
||
@app.delete("/api/operator-names/{name_id}")
|
||
async def delete_operator_name(name_id: int, conn = Depends(get_db), current_user = Depends(get_current_user)):
|
||
if current_user['role'] != 'admin':
|
||
raise HTTPException(status_code=403, detail="Только для администратора")
|
||
cursor = conn.cursor()
|
||
cursor.execute("DELETE FROM operator_names WHERE id = %s", (name_id,))
|
||
conn.commit()
|
||
return {"status": "ok"}
|
||
|
||
@app.post("/api/operator-names/refresh-cache")
|
||
async def refresh_operator_names_cache(current_user = Depends(get_current_user)):
|
||
if current_user['role'] != 'admin':
|
||
raise HTTPException(status_code=403, detail="Только для администратора")
|
||
try:
|
||
response = requests.post(f'http://{ASR_HOST}:{ASR_CMD_PORT}/reload-names', timeout=5)
|
||
if response.status_code == 200:
|
||
data = response.json()
|
||
return {"status": "ok", "message": f"Кэш обновлён, загружено {data.get('count', 0)} имён"}
|
||
else:
|
||
return {"status": "warning", "message": "Воркер не ответил"}
|
||
except Exception as e:
|
||
return {"status": "warning", "message": f"Ошибка: {e}"}
|
||
|
||
@app.get("/api/health")
|
||
async def health():
|
||
return {"status": "ok"}
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||
EOF
|
||
|
||
echo "=== 6. Создание systemd сервиса для API с переменными окружения ==="
|
||
cat > /etc/systemd/system/asr-api.service << EOF
|
||
[Unit]
|
||
Description=ASR FastAPI
|
||
After=network.target
|
||
|
||
[Service]
|
||
Type=simple
|
||
User=root
|
||
WorkingDirectory=/opt/asr-web
|
||
Environment="DB_HOST=$DB_HOST"
|
||
Environment="DB_PORT=$DB_PORT"
|
||
Environment="DB_NAME=$DB_NAME"
|
||
Environment="DB_USER=$DB_USER"
|
||
Environment="DB_PASSWORD=$DB_PASSWORD"
|
||
Environment="ASR_HOST=$ASR_HOST"
|
||
Environment="ASR_AUDIO_PORT=$ASR_AUDIO_PORT"
|
||
Environment="ASR_CMD_PORT=$ASR_CMD_PORT"
|
||
ExecStart=/opt/asr-web/venv/bin/python /opt/asr-web/main.py
|
||
Restart=always
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
EOF
|
||
|
||
echo "=== 7. Настройка Nginx ==="
|
||
cat > /etc/nginx/sites-available/asr-frontend << EOF
|
||
server {
|
||
listen 80;
|
||
server_name $WEB_HOST;
|
||
|
||
root /opt/asr-web/frontend/dist;
|
||
index index.html;
|
||
|
||
location / {
|
||
try_files \$uri \$uri/ /index.html;
|
||
}
|
||
|
||
location /api/ {
|
||
proxy_pass http://localhost:8000/api/;
|
||
proxy_set_header Host \$host;
|
||
}
|
||
|
||
location /audio/ {
|
||
proxy_pass http://$ASR_HOST:$ASR_AUDIO_PORT/audio/;
|
||
add_header Access-Control-Allow-Origin *;
|
||
}
|
||
}
|
||
EOF
|
||
|
||
ln -sf /etc/nginx/sites-available/asr-frontend /etc/nginx/sites-enabled/
|
||
rm -f /etc/nginx/sites-enabled/default
|
||
nginx -t
|
||
systemctl restart nginx
|
||
|
||
echo "=== 8. Базовый фронтенд (требуется копирование src из работающей ВМ) ==="
|
||
cd /opt/asr-web
|
||
npm create vite@5 frontend -- --template vue
|
||
cd frontend
|
||
npm install
|
||
npm install vuetify@3 axios vue-router @mdi/font chart.js
|
||
|
||
mkdir -p src/plugins
|
||
cat > src/plugins/vuetify.js << 'EOF'
|
||
import 'vuetify/styles'
|
||
import { createVuetify } from 'vuetify'
|
||
import * as components from 'vuetify/components'
|
||
import * as directives from 'vuetify/directives'
|
||
import '@mdi/font/css/materialdesignicons.css'
|
||
|
||
export default createVuetify({
|
||
components,
|
||
directives,
|
||
theme: {
|
||
defaultTheme: 'light',
|
||
themes: {
|
||
light: {
|
||
colors: {
|
||
primary: '#1976D2',
|
||
secondary: '#424242',
|
||
accent: '#82B1FF',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
})
|
||
EOF
|
||
|
||
cat > src/main.js << 'EOF'
|
||
import { createApp } from 'vue'
|
||
import App from './App.vue'
|
||
import vuetify from './plugins/vuetify'
|
||
|
||
createApp(App).use(vuetify).mount('#app')
|
||
EOF
|
||
|
||
# Базовый App.vue (минимальный)
|
||
cat > src/App.vue << 'EOF'
|
||
<template>
|
||
<v-app>
|
||
<v-main>
|
||
<v-container>
|
||
<v-card>
|
||
<v-card-title>ASR Аналитика</v-card-title>
|
||
<v-card-text>
|
||
<p>Для полноценной работы скопируйте компоненты из работающей ВМ в папку /opt/asr-web/frontend/src/components/</p>
|
||
<p>Затем выполните npm run build</p>
|
||
</v-card-text>
|
||
</v-card>
|
||
</v-container>
|
||
</v-main>
|
||
</v-app>
|
||
</template>
|
||
|
||
<script>
|
||
export default {
|
||
data() {
|
||
return {}
|
||
}
|
||
}
|
||
</script>
|
||
EOF
|
||
|
||
echo "=== 9. Запуск сервисов ==="
|
||
systemctl daemon-reload
|
||
systemctl enable asr-api
|
||
systemctl start asr-api
|
||
systemctl restart nginx
|
||
|
||
echo "=== Web ВМ готова ==="
|
||
echo "API: http://$WEB_HOST:8000"
|
||
echo "Frontend: http://$WEB_HOST"
|
||
echo ""
|
||
echo "⚠️ ВАЖНО: Для полноценной работы фронтенда скопируйте компоненты из работающей ВМ:"
|
||
echo " /opt/asr-web/frontend/src/components/"
|
||
echo " /opt/asr-web/frontend/src/App.vue (полную версию)"
|
||
echo " /opt/asr-web/frontend/src/main.js (с импортами всех компонентов)"
|
||
echo ""
|
||
echo "Затем выполните: cd /opt/asr-web/frontend && npm run build" |