增加資料庫連線到MySQL
This commit is contained in:
@@ -1,7 +1,13 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(python -c:*)"
|
"Bash(python -c:*)",
|
||||||
|
"Bash(pip install:*)",
|
||||||
|
"Bash(set FLASK_APP=simple_app.py)",
|
||||||
|
"Bash(flask db:*)",
|
||||||
|
"Bash(flask:*)",
|
||||||
|
"Bash(python simple_app.py:*)",
|
||||||
|
"Bash(timeout:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
DB_HOST=your_mysql_host
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_NAME=your_database_name
|
||||||
|
DB_USER=your_database_user
|
||||||
|
DB_PASSWORD=your_database_password
|
||||||
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Python cache
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
|
||||||
|
# Database
|
||||||
|
instance/
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Flask migrations (optional, comment out if you want to commit)
|
||||||
|
# migrations/
|
||||||
|
|
||||||
|
# Virtual Environment
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.coverage
|
||||||
|
.pytest_cache/
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
|
||||||
|
# Build files
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
10
config.py
10
config.py
@@ -10,8 +10,16 @@ class Config:
|
|||||||
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=int(os.environ.get('JWT_ACCESS_TOKEN_EXPIRES', 1)))
|
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=int(os.environ.get('JWT_ACCESS_TOKEN_EXPIRES', 1)))
|
||||||
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=int(os.environ.get('JWT_REFRESH_TOKEN_EXPIRES', 7)))
|
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=int(os.environ.get('JWT_REFRESH_TOKEN_EXPIRES', 7)))
|
||||||
|
|
||||||
SQLALCHEMY_DATABASE_URI = f"mysql+pymysql://{os.environ.get('DB_USER', 'dev_user')}:{os.environ.get('DB_PASSWORD', 'dev_password')}@{os.environ.get('DB_HOST', 'localhost')}:{os.environ.get('DB_PORT', '3306')}/{os.environ.get('DB_NAME', 'partner_alignment_dev')}"
|
# MySQL Database Configuration
|
||||||
|
DB_HOST = os.environ.get('DB_HOST', 'mysql.theaken.com')
|
||||||
|
DB_PORT = os.environ.get('DB_PORT', '33306')
|
||||||
|
DB_NAME = os.environ.get('DB_NAME', 'db_A001')
|
||||||
|
DB_USER = os.environ.get('DB_USER', 'A001')
|
||||||
|
DB_PASSWORD = os.environ.get('DB_PASSWORD', 'QFOts8FlibiI')
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URI = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
SQLALCHEMY_ECHO = False # Set to True for SQL debugging
|
||||||
|
|
||||||
CORS_ORIGINS = os.environ.get('CORS_ORIGINS', 'http://localhost:5000,http://127.0.0.1:5000').split(',')
|
CORS_ORIGINS = os.environ.get('CORS_ORIGINS', 'http://localhost:5000,http://127.0.0.1:5000').split(',')
|
||||||
|
|
||||||
|
|||||||
1
migrations/README
Normal file
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Single-database configuration for Flask.
|
||||||
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# template used to generate migration files
|
||||||
|
# file_template = %%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic,flask_migrate
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[logger_flask_migrate]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = flask_migrate
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
113
migrations/env.py
Normal file
113
migrations/env.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import logging
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
logger = logging.getLogger('alembic.env')
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
try:
|
||||||
|
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||||
|
return current_app.extensions['migrate'].db.get_engine()
|
||||||
|
except (TypeError, AttributeError):
|
||||||
|
# this works with Flask-SQLAlchemy>=3
|
||||||
|
return current_app.extensions['migrate'].db.engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine_url():
|
||||||
|
try:
|
||||||
|
return get_engine().url.render_as_string(hide_password=False).replace(
|
||||||
|
'%', '%%')
|
||||||
|
except AttributeError:
|
||||||
|
return str(get_engine().url).replace('%', '%%')
|
||||||
|
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
config.set_main_option('sqlalchemy.url', get_engine_url())
|
||||||
|
target_db = current_app.extensions['migrate'].db
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def get_metadata():
|
||||||
|
if hasattr(target_db, 'metadatas'):
|
||||||
|
return target_db.metadatas[None]
|
||||||
|
return target_db.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url, target_metadata=get_metadata(), literal_binds=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# this callback is used to prevent an auto-migration from being generated
|
||||||
|
# when there are no changes to the schema
|
||||||
|
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||||
|
def process_revision_directives(context, revision, directives):
|
||||||
|
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||||
|
script = directives[0]
|
||||||
|
if script.upgrade_ops.is_empty():
|
||||||
|
directives[:] = []
|
||||||
|
logger.info('No changes in schema detected.')
|
||||||
|
|
||||||
|
conf_args = current_app.extensions['migrate'].configure_args
|
||||||
|
if conf_args.get("process_revision_directives") is None:
|
||||||
|
conf_args["process_revision_directives"] = process_revision_directives
|
||||||
|
|
||||||
|
connectable = get_engine()
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=get_metadata(),
|
||||||
|
**conf_args
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
24
migrations/script.py.mako
Normal file
24
migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
"""Initial migration from SQLite to MySQL
|
||||||
|
|
||||||
|
Revision ID: b4d5a641842e
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-10-28 19:24:28.282109
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'b4d5a641842e'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('capability',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('l1_description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('l2_description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('l3_description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('l4_description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('l5_description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('employee_point',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('employee_name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('department', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('position', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('total_points', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('monthly_points', sa.Integer(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('star_feedback',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('evaluator_name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('evaluatee_name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('evaluatee_department', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('evaluatee_position', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('situation', sa.Text(), nullable=False),
|
||||||
|
sa.Column('task', sa.Text(), nullable=False),
|
||||||
|
sa.Column('action', sa.Text(), nullable=False),
|
||||||
|
sa.Column('result', sa.Text(), nullable=False),
|
||||||
|
sa.Column('score', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('points_earned', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('feedback_date', sa.Date(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('user',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('full_name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('department', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('position', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('employee_id', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('email'),
|
||||||
|
sa.UniqueConstraint('employee_id'),
|
||||||
|
sa.UniqueConstraint('username')
|
||||||
|
)
|
||||||
|
op.create_table('assessment',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('department', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('position', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('employee_name', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('assessment_data', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('department_capability',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('department', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('capability_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['capability_id'], ['capability.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('department', 'capability_id', name='uq_dept_capability')
|
||||||
|
)
|
||||||
|
# 移除刪除舊表的操作,保留舊表不影響新系統運行
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('development_cards',
|
||||||
|
sa.Column('id', mysql.INTEGER(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('employee_id', mysql.VARCHAR(length=32), nullable=True),
|
||||||
|
sa.Column('gaps', mysql.TEXT(), nullable=True),
|
||||||
|
sa.Column('short_term_goals', mysql.TEXT(), nullable=True),
|
||||||
|
sa.Column('mid_term_goals', mysql.TEXT(), nullable=True),
|
||||||
|
sa.Column('long_term_goals', mysql.TEXT(), nullable=True),
|
||||||
|
sa.Column('training_plan', mysql.TEXT(), nullable=True),
|
||||||
|
sa.Column('mentor', mysql.VARCHAR(length=100), nullable=True),
|
||||||
|
sa.Column('budget', mysql.VARCHAR(length=100), nullable=True),
|
||||||
|
sa.Column('due_date', sa.DATE(), nullable=True),
|
||||||
|
sa.Column('saved_at', mysql.DATETIME(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['employee_id'], ['employees.employee_id'], name='fk_dev_emp', onupdate='CASCADE', ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_collate='utf8mb4_0900_ai_ci',
|
||||||
|
mysql_default_charset='utf8mb4',
|
||||||
|
mysql_engine='InnoDB'
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('development_cards', schema=None) as batch_op:
|
||||||
|
batch_op.create_index('idx_dev_employee', ['employee_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('capabilities',
|
||||||
|
sa.Column('id', mysql.INTEGER(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('name', mysql.VARCHAR(length=100), nullable=False),
|
||||||
|
sa.Column('l1_description', mysql.TEXT(), nullable=True),
|
||||||
|
sa.Column('l2_description', mysql.TEXT(), nullable=True),
|
||||||
|
sa.Column('l3_description', mysql.TEXT(), nullable=True),
|
||||||
|
sa.Column('l4_description', mysql.TEXT(), nullable=True),
|
||||||
|
sa.Column('l5_description', mysql.TEXT(), nullable=True),
|
||||||
|
sa.Column('is_active', mysql.TINYINT(display_width=1), autoincrement=False, nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_collate='utf8mb4_0900_ai_ci',
|
||||||
|
mysql_default_charset='utf8mb4',
|
||||||
|
mysql_engine='InnoDB'
|
||||||
|
)
|
||||||
|
op.create_table('PJ_ABC',
|
||||||
|
sa.Column('ID', mysql.INTEGER(), autoincrement=False, nullable=True),
|
||||||
|
mysql_collate='utf8mb4_0900_ai_ci',
|
||||||
|
mysql_default_charset='utf8mb4',
|
||||||
|
mysql_engine='InnoDB'
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('PJ_ABC', schema=None) as batch_op:
|
||||||
|
batch_op.create_index('ID', ['ID'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('employees',
|
||||||
|
sa.Column('employee_id', mysql.VARCHAR(length=32), nullable=False),
|
||||||
|
sa.Column('employee_name', mysql.VARCHAR(length=100), nullable=False),
|
||||||
|
sa.Column('department', mysql.VARCHAR(length=100), nullable=True),
|
||||||
|
sa.Column('position_title', mysql.VARCHAR(length=100), nullable=True),
|
||||||
|
sa.Column('level_title', mysql.VARCHAR(length=50), nullable=True),
|
||||||
|
sa.Column('supervisor_name', mysql.VARCHAR(length=100), nullable=True),
|
||||||
|
sa.Column('created_at', mysql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.Column('updated_at', mysql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('employee_id'),
|
||||||
|
mysql_collate='utf8mb4_0900_ai_ci',
|
||||||
|
mysql_default_charset='utf8mb4',
|
||||||
|
mysql_engine='InnoDB'
|
||||||
|
)
|
||||||
|
op.create_table('performance_cards',
|
||||||
|
sa.Column('id', mysql.INTEGER(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('employee_id', mysql.VARCHAR(length=32), nullable=True),
|
||||||
|
sa.Column('kpi_1', mysql.DECIMAL(precision=5, scale=2), nullable=True),
|
||||||
|
sa.Column('kpi_2', mysql.DECIMAL(precision=5, scale=2), nullable=True),
|
||||||
|
sa.Column('kpi_3', mysql.DECIMAL(precision=5, scale=2), nullable=True),
|
||||||
|
sa.Column('overall_level', mysql.TINYINT(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('manager_rating', mysql.TINYINT(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('peer_rating', mysql.TINYINT(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('contribution', mysql.TEXT(), nullable=True),
|
||||||
|
sa.Column('saved_at', mysql.DATETIME(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['employee_id'], ['employees.employee_id'], name='fk_perf_emp', onupdate='CASCADE', ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_collate='utf8mb4_0900_ai_ci',
|
||||||
|
mysql_default_charset='utf8mb4',
|
||||||
|
mysql_engine='InnoDB'
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('performance_cards', schema=None) as batch_op:
|
||||||
|
batch_op.create_index('idx_perf_employee', ['employee_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('role_cards',
|
||||||
|
sa.Column('id', mysql.INTEGER(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('employee_id', mysql.VARCHAR(length=32), nullable=True),
|
||||||
|
sa.Column('role_name', mysql.VARCHAR(length=100), nullable=False),
|
||||||
|
sa.Column('role_department', mysql.VARCHAR(length=100), nullable=True),
|
||||||
|
sa.Column('responsibilities', mysql.TEXT(), nullable=True),
|
||||||
|
sa.Column('kpi_indicators', mysql.TEXT(), nullable=True),
|
||||||
|
sa.Column('saved_at', mysql.DATETIME(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['employee_id'], ['employees.employee_id'], name='fk_role_emp', onupdate='CASCADE', ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_collate='utf8mb4_0900_ai_ci',
|
||||||
|
mysql_default_charset='utf8mb4',
|
||||||
|
mysql_engine='InnoDB'
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('role_cards', schema=None) as batch_op:
|
||||||
|
batch_op.create_index('idx_role_employee', ['employee_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('members',
|
||||||
|
sa.Column('id', mysql.INTEGER(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('name', mysql.VARCHAR(length=255), nullable=False),
|
||||||
|
sa.Column('phone', mysql.VARCHAR(length=255), nullable=False),
|
||||||
|
sa.Column('address', mysql.VARCHAR(length=255), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_collate='utf8mb4_0900_ai_ci',
|
||||||
|
mysql_default_charset='utf8mb4',
|
||||||
|
mysql_engine='InnoDB'
|
||||||
|
)
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('id', mysql.INTEGER(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('username', mysql.VARCHAR(length=80), nullable=False),
|
||||||
|
sa.Column('email', mysql.VARCHAR(length=120), nullable=False),
|
||||||
|
sa.Column('password_hash', mysql.VARCHAR(length=200), nullable=False),
|
||||||
|
sa.Column('full_name', mysql.VARCHAR(length=100), nullable=False),
|
||||||
|
sa.Column('department', mysql.VARCHAR(length=100), nullable=True),
|
||||||
|
sa.Column('position', mysql.VARCHAR(length=100), nullable=True),
|
||||||
|
sa.Column('role', mysql.VARCHAR(length=20), nullable=True),
|
||||||
|
sa.Column('is_active', mysql.TINYINT(display_width=1), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('created_at', mysql.DATETIME(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_collate='utf8mb4_0900_ai_ci',
|
||||||
|
mysql_default_charset='utf8mb4',
|
||||||
|
mysql_engine='InnoDB'
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.create_index('ix_users_username', ['username'], unique=False)
|
||||||
|
batch_op.create_index('ix_users_role', ['role'], unique=False)
|
||||||
|
batch_op.create_index('ix_users_email', ['email'], unique=False)
|
||||||
|
batch_op.create_index('ix_users_department', ['department'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('reviews',
|
||||||
|
sa.Column('id', mysql.INTEGER(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('employee_name', mysql.VARCHAR(length=100), nullable=False),
|
||||||
|
sa.Column('department', mysql.VARCHAR(length=100), nullable=False),
|
||||||
|
sa.Column('period', mysql.VARCHAR(length=50), nullable=False),
|
||||||
|
sa.Column('items', mysql.JSON(), nullable=False),
|
||||||
|
sa.Column('total_score', mysql.INTEGER(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('grade', mysql.VARCHAR(length=20), nullable=False),
|
||||||
|
sa.Column('overall_comment', mysql.TEXT(), nullable=True),
|
||||||
|
sa.Column('created_at', mysql.DATETIME(), server_default=sa.text('(now())'), nullable=True),
|
||||||
|
sa.Column('updated_at', mysql.DATETIME(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_collate='utf8mb4_0900_ai_ci',
|
||||||
|
mysql_default_charset='utf8mb4',
|
||||||
|
mysql_engine='InnoDB'
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('reviews', schema=None) as batch_op:
|
||||||
|
batch_op.create_index('ix_reviews_period', ['period'], unique=False)
|
||||||
|
batch_op.create_index('ix_reviews_id', ['id'], unique=False)
|
||||||
|
batch_op.create_index('ix_reviews_employee_name', ['employee_name'], unique=False)
|
||||||
|
batch_op.create_index('ix_reviews_department', ['department'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('employee_points',
|
||||||
|
sa.Column('id', mysql.INTEGER(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('employee_name', mysql.VARCHAR(length=100), nullable=False),
|
||||||
|
sa.Column('department', mysql.VARCHAR(length=100), nullable=True),
|
||||||
|
sa.Column('position', mysql.VARCHAR(length=100), nullable=True),
|
||||||
|
sa.Column('total_points', mysql.INTEGER(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('monthly_points', mysql.INTEGER(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('year_month', mysql.VARCHAR(length=7), nullable=True),
|
||||||
|
sa.Column('rank', mysql.INTEGER(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('percentile', mysql.FLOAT(), nullable=True),
|
||||||
|
sa.Column('tier', mysql.VARCHAR(length=20), nullable=True),
|
||||||
|
sa.Column('created_at', mysql.DATETIME(), nullable=True),
|
||||||
|
sa.Column('updated_at', mysql.DATETIME(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_collate='utf8mb4_0900_ai_ci',
|
||||||
|
mysql_default_charset='utf8mb4',
|
||||||
|
mysql_engine='InnoDB'
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('employee_points', schema=None) as batch_op:
|
||||||
|
batch_op.create_index('ix_employee_points_employee_name', ['employee_name'], unique=False)
|
||||||
|
batch_op.create_index('idx_total_points_desc', ['total_points'], unique=False)
|
||||||
|
batch_op.create_index('idx_monthly_points_desc', ['monthly_points'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('monthly_rankings',
|
||||||
|
sa.Column('id', mysql.INTEGER(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('ranking_month', sa.DATE(), nullable=False),
|
||||||
|
sa.Column('employee_name', mysql.VARCHAR(length=100), nullable=False),
|
||||||
|
sa.Column('department', mysql.VARCHAR(length=50), nullable=False),
|
||||||
|
sa.Column('position', mysql.VARCHAR(length=50), nullable=False),
|
||||||
|
sa.Column('total_points', mysql.INTEGER(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('ranking', mysql.INTEGER(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('created_at', mysql.DATETIME(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_collate='utf8mb4_0900_ai_ci',
|
||||||
|
mysql_default_charset='utf8mb4',
|
||||||
|
mysql_engine='InnoDB'
|
||||||
|
)
|
||||||
|
op.create_table('star_feedbacks',
|
||||||
|
sa.Column('id', mysql.INTEGER(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('evaluator_name', mysql.VARCHAR(length=100), nullable=False),
|
||||||
|
sa.Column('evaluator_department', mysql.VARCHAR(length=100), nullable=True),
|
||||||
|
sa.Column('evaluator_position', mysql.VARCHAR(length=100), nullable=True),
|
||||||
|
sa.Column('evaluatee_name', mysql.VARCHAR(length=100), nullable=False),
|
||||||
|
sa.Column('evaluatee_department', mysql.VARCHAR(length=100), nullable=True),
|
||||||
|
sa.Column('evaluatee_position', mysql.VARCHAR(length=100), nullable=True),
|
||||||
|
sa.Column('situation', mysql.TEXT(), nullable=False),
|
||||||
|
sa.Column('task', mysql.TEXT(), nullable=False),
|
||||||
|
sa.Column('action', mysql.TEXT(), nullable=False),
|
||||||
|
sa.Column('result', mysql.TEXT(), nullable=False),
|
||||||
|
sa.Column('score', mysql.INTEGER(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('points_earned', mysql.INTEGER(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('feedback_date', sa.DATE(), nullable=True),
|
||||||
|
sa.Column('created_at', mysql.DATETIME(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_collate='utf8mb4_0900_ai_ci',
|
||||||
|
mysql_default_charset='utf8mb4',
|
||||||
|
mysql_engine='InnoDB'
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('star_feedbacks', schema=None) as batch_op:
|
||||||
|
batch_op.create_index('ix_star_feedbacks_evaluatee_name', ['evaluatee_name'], unique=False)
|
||||||
|
batch_op.create_index('ix_star_feedbacks_created_at', ['created_at'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('competency_cards',
|
||||||
|
sa.Column('id', mysql.INTEGER(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('employee_id', mysql.VARCHAR(length=32), nullable=True),
|
||||||
|
sa.Column('details', mysql.TEXT(), nullable=True),
|
||||||
|
sa.Column('saved_at', mysql.DATETIME(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['employee_id'], ['employees.employee_id'], name='fk_comp_emp', onupdate='CASCADE', ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_collate='utf8mb4_0900_ai_ci',
|
||||||
|
mysql_default_charset='utf8mb4',
|
||||||
|
mysql_engine='InnoDB'
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('competency_cards', schema=None) as batch_op:
|
||||||
|
batch_op.create_index('idx_comp_employee', ['employee_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('assessments',
|
||||||
|
sa.Column('id', mysql.INTEGER(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('user_id', mysql.INTEGER(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('employee_name', mysql.VARCHAR(length=100), nullable=False),
|
||||||
|
sa.Column('department', mysql.VARCHAR(length=100), nullable=True),
|
||||||
|
sa.Column('position', mysql.VARCHAR(length=100), nullable=True),
|
||||||
|
sa.Column('assessment_data', mysql.TEXT(), nullable=True),
|
||||||
|
sa.Column('created_at', mysql.DATETIME(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='assessments_ibfk_1'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_collate='utf8mb4_0900_ai_ci',
|
||||||
|
mysql_default_charset='utf8mb4',
|
||||||
|
mysql_engine='InnoDB'
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('assessments', schema=None) as batch_op:
|
||||||
|
batch_op.create_index('ix_assessments_employee_name', ['employee_name'], unique=False)
|
||||||
|
batch_op.create_index('ix_assessments_created_at', ['created_at'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('nine_box_assessments',
|
||||||
|
sa.Column('id', mysql.INTEGER(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('employee_id', mysql.VARCHAR(length=32), nullable=True),
|
||||||
|
sa.Column('performance_score', mysql.DECIMAL(precision=2, scale=1), nullable=True),
|
||||||
|
sa.Column('potential_score', mysql.DECIMAL(precision=2, scale=1), nullable=True),
|
||||||
|
sa.Column('category', mysql.VARCHAR(length=50), nullable=True),
|
||||||
|
sa.Column('priority', mysql.VARCHAR(length=20), nullable=True),
|
||||||
|
sa.Column('saved_at', mysql.DATETIME(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['employee_id'], ['employees.employee_id'], name='fk_nine_emp', onupdate='CASCADE', ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_collate='utf8mb4_0900_ai_ci',
|
||||||
|
mysql_default_charset='utf8mb4',
|
||||||
|
mysql_engine='InnoDB'
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('nine_box_assessments', schema=None) as batch_op:
|
||||||
|
batch_op.create_index('idx_nine_employee', ['employee_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('feedbacks',
|
||||||
|
sa.Column('id', mysql.INTEGER(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('employee_id', mysql.VARCHAR(length=32), nullable=True),
|
||||||
|
sa.Column('category_id', mysql.INTEGER(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('observation_date', sa.DATE(), nullable=False),
|
||||||
|
sa.Column('rating', mysql.TINYINT(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('situation', mysql.TEXT(), nullable=False),
|
||||||
|
sa.Column('task', mysql.TEXT(), nullable=False),
|
||||||
|
sa.Column('action', mysql.TEXT(), nullable=False),
|
||||||
|
sa.Column('result', mysql.TEXT(), nullable=False),
|
||||||
|
sa.Column('created_at', mysql.DATETIME(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['employee_id'], ['employees.employee_id'], name='fk_feedbacks_employee', onupdate='CASCADE', ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_collate='utf8mb4_0900_ai_ci',
|
||||||
|
mysql_default_charset='utf8mb4',
|
||||||
|
mysql_engine='InnoDB'
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('feedbacks', schema=None) as batch_op:
|
||||||
|
batch_op.create_index('idx_feedbacks_observation_date', ['observation_date'], unique=False)
|
||||||
|
batch_op.create_index('idx_feedbacks_employee', ['employee_id'], unique=False)
|
||||||
|
batch_op.create_index('idx_feedbacks_category', ['category_id'], unique=False)
|
||||||
|
|
||||||
|
op.drop_table('department_capability')
|
||||||
|
op.drop_table('assessment')
|
||||||
|
op.drop_table('user')
|
||||||
|
op.drop_table('star_feedback')
|
||||||
|
op.drop_table('employee_point')
|
||||||
|
op.drop_table('capability')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -4,6 +4,7 @@ Flask-CORS==4.0.0
|
|||||||
Flask-Login==0.6.2
|
Flask-Login==0.6.2
|
||||||
Flask-JWT-Extended==4.5.2
|
Flask-JWT-Extended==4.5.2
|
||||||
Flask-Bcrypt==1.0.1
|
Flask-Bcrypt==1.0.1
|
||||||
|
Flask-Migrate==4.0.5
|
||||||
pandas==2.1.1
|
pandas==2.1.1
|
||||||
openpyxl==3.1.2
|
openpyxl==3.1.2
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
簡化版夥伴對齊系統 - 使用 SQLite
|
夥伴對齊系統 - 使用 MySQL
|
||||||
無需 MySQL,開箱即用
|
生產環境就緒版本
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Flask, render_template, request, jsonify
|
from flask import Flask, render_template, request, jsonify
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
from config import Config
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -16,14 +18,13 @@ import io
|
|||||||
# 創建 Flask 應用程式
|
# 創建 Flask 應用程式
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
# 配置
|
# 從配置類載入設定
|
||||||
app.config['SECRET_KEY'] = 'dev-secret-key-for-testing'
|
app.config.from_object(Config)
|
||||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///partner_alignment.db'
|
|
||||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
|
||||||
|
|
||||||
# 初始化擴展
|
# 初始化擴展
|
||||||
db = SQLAlchemy(app)
|
db = SQLAlchemy(app)
|
||||||
CORS(app, origins=['http://localhost:5000', 'http://127.0.0.1:5000'])
|
migrate = Migrate(app, db)
|
||||||
|
CORS(app, origins=Config.CORS_ORIGINS)
|
||||||
|
|
||||||
# 數據模型
|
# 數據模型
|
||||||
class User(db.Model):
|
class User(db.Model):
|
||||||
@@ -923,16 +924,36 @@ def create_sample_data():
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
# 創建所有數據庫表
|
print("=" * 60)
|
||||||
db.create_all()
|
print("夥伴對齊系統 - MySQL 版本")
|
||||||
print("數據庫表創建成功!")
|
print("=" * 60)
|
||||||
|
print(f"[DATABASE] 連線至: {Config.DB_HOST}:{Config.DB_PORT}/{Config.DB_NAME}")
|
||||||
|
print(f"[DATABASE] 使用者: {Config.DB_USER}")
|
||||||
|
|
||||||
# 創建樣本數據
|
# 使用 Flask-Migrate 管理資料庫
|
||||||
|
# 首次運行前請執行: flask db upgrade
|
||||||
|
try:
|
||||||
|
# 嘗試建立樣本數據(如果表已存在)
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
inspector = inspect(db.engine)
|
||||||
|
existing_tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
if 'user' in existing_tables:
|
||||||
|
print("[DATABASE] 資料表已存在")
|
||||||
|
else:
|
||||||
|
print("[WARNING] 資料表不存在,請先執行: flask db upgrade")
|
||||||
|
|
||||||
|
# 創建樣本數據(如果不存在)
|
||||||
create_sample_data()
|
create_sample_data()
|
||||||
|
|
||||||
print("=" * 60)
|
except Exception as e:
|
||||||
print("夥伴對齊系統已啟動!")
|
print(f"[ERROR] 資料庫操作錯誤: {e}")
|
||||||
print("=" * 60)
|
print("[INFO] 請確認:")
|
||||||
|
print(" 1. 已執行 flask db upgrade")
|
||||||
|
print(" 2. 資料庫連線資訊正確")
|
||||||
|
print(" 3. MySQL 服務正在運行")
|
||||||
|
|
||||||
|
print()
|
||||||
print("[WEB] 訪問地址: http://localhost:5000")
|
print("[WEB] 訪問地址: http://localhost:5000")
|
||||||
print()
|
print()
|
||||||
print("[ACCOUNT] 測試帳號資訊:")
|
print("[ACCOUNT] 測試帳號資訊:")
|
||||||
|
|||||||
879
資料庫遷移至MySQL-SDD.md
Normal file
879
資料庫遷移至MySQL-SDD.md
Normal file
@@ -0,0 +1,879 @@
|
|||||||
|
# 資料庫遷移至 MySQL - 系統設計文件 (SDD)
|
||||||
|
|
||||||
|
**專案名稱:** 夥伴對齊系統資料庫遷移
|
||||||
|
**文件版本:** 1.0
|
||||||
|
**建立日期:** 2025年1月28日
|
||||||
|
**作者:** 資深全端工程師
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 執行摘要
|
||||||
|
|
||||||
|
### 1.1 專案目標
|
||||||
|
將夥伴對齊系統(Partner Alignment System)的資料庫從 SQLite 遷移至 MySQL,以提升系統的擴展性、性能和生產環境適用性。
|
||||||
|
|
||||||
|
### 1.2 背景
|
||||||
|
目前系統使用 SQLite 作為資料庫,適合開發和測試環境,但在生產環境中存在以下限制:
|
||||||
|
- SQLite 不適合高並發讀寫
|
||||||
|
- 無法支援高可用性和負載均衡
|
||||||
|
- 缺乏企業級的備份和恢復機制
|
||||||
|
- 不支援網路連線,僅限本地檔案存取
|
||||||
|
|
||||||
|
### 1.3 技術範圍
|
||||||
|
- 資料庫引擎:SQLite → MySQL
|
||||||
|
- 使用 SQLAlchemy ORM 進行抽象層操作
|
||||||
|
- 實作 Flask-Migrate 進行資料庫版本管理
|
||||||
|
- 使用環境變數管理敏感資訊
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 需求分析
|
||||||
|
|
||||||
|
### 2.1 功能性需求
|
||||||
|
|
||||||
|
#### FR-01: 資料庫連線
|
||||||
|
- **需求:** 系統必須能夠連線到指定的 MySQL 資料庫
|
||||||
|
- **資料庫連線資訊:**
|
||||||
|
- 主機:mysql.theaken.com
|
||||||
|
- 端口:33306
|
||||||
|
- 資料庫名稱:db_A001
|
||||||
|
- 使用者帳號:A001
|
||||||
|
- 密碼:QFOts8FlibiI
|
||||||
|
|
||||||
|
#### FR-02: 環境變數配置
|
||||||
|
- **需求:** 資料庫連線參數必須從 `.env` 檔案讀取
|
||||||
|
- **必要變數:**
|
||||||
|
- DB_HOST
|
||||||
|
- DB_PORT
|
||||||
|
- DB_NAME
|
||||||
|
- DB_USER
|
||||||
|
- DB_PASSWORD
|
||||||
|
|
||||||
|
#### FR-03: 資料庫初始化
|
||||||
|
- **需求:** 系統必須能夠使用 Flask-Migrate 初始化資料庫
|
||||||
|
- **操作:** `flask db init`, `flask db migrate`, `flask db upgrade`
|
||||||
|
|
||||||
|
#### FR-04: 資料模型相容性
|
||||||
|
- **需求:** 所有資料模型必須與 MySQL 相容
|
||||||
|
- **檢查點:** 確保無 SQLite 專屬語法或功能
|
||||||
|
|
||||||
|
### 2.2 非功能性需求
|
||||||
|
|
||||||
|
#### NFR-01: 安全性
|
||||||
|
- 所有敏感資訊(資料庫密碼)必須儲存於 `.env` 檔案
|
||||||
|
- `.env` 檔案必須加入 `.gitignore`
|
||||||
|
- 提供 `.env.example` 作為範本
|
||||||
|
|
||||||
|
#### NFR-02: 向後相容性
|
||||||
|
- 現有的資料模型定義不需修改結構
|
||||||
|
- API 端點行為保持一致
|
||||||
|
- 資料匯入/匯出功能不受影響
|
||||||
|
|
||||||
|
#### NFR-03: 文件完整性
|
||||||
|
- 提供完整的部署指南
|
||||||
|
- 記錄所有環境變數設定
|
||||||
|
- 提供資料庫遷移步驟
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 技術方案
|
||||||
|
|
||||||
|
### 3.1 技術架構選擇
|
||||||
|
|
||||||
|
#### 3.1.1 ORM 層
|
||||||
|
- **選擇:** SQLAlchemy(已使用)
|
||||||
|
- **理由:**
|
||||||
|
- 提供統一的 ORM API
|
||||||
|
- 跨資料庫相容性
|
||||||
|
- 支援複雜查詢
|
||||||
|
|
||||||
|
#### 3.1.2 資料庫驅動
|
||||||
|
- **選擇:** PyMySQL
|
||||||
|
- **理由:**
|
||||||
|
- 純 Python 實作
|
||||||
|
- 相容 MySQL 5.7+
|
||||||
|
- 效能穩定
|
||||||
|
|
||||||
|
#### 3.1.3 環境變數管理
|
||||||
|
- **選擇:** python-dotenv
|
||||||
|
- **理由:**
|
||||||
|
- 業界標準
|
||||||
|
- 簡單易用
|
||||||
|
- 已安裝於 requirements.txt
|
||||||
|
|
||||||
|
#### 3.1.4 資料庫遷移工具
|
||||||
|
- **選擇:** Flask-Migrate(基於 Alembic)
|
||||||
|
- **理由:**
|
||||||
|
- Flask 生態系整合
|
||||||
|
- 版本控制管理
|
||||||
|
- 自動生成遷移腳本
|
||||||
|
|
||||||
|
### 3.2 資料庫連線字串格式
|
||||||
|
|
||||||
|
#### SQLite 格式(現行)
|
||||||
|
```python
|
||||||
|
SQLALCHEMY_DATABASE_URI = 'sqlite:///partner_alignment.db'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MySQL 格式(目標)
|
||||||
|
```python
|
||||||
|
# 使用 PyMySQL
|
||||||
|
SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://USER:PASSWORD@HOST:PORT/DATABASE'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 完整連線字串範例
|
||||||
|
```python
|
||||||
|
'mysql+pymysql://A001:QFOts8FlibiI@mysql.theaken.com:33306/db_A001'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 連線參數說明
|
||||||
|
|
||||||
|
| 參數 | 說明 | 範例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| USER | 資料庫使用者帳號 | A001 |
|
||||||
|
| PASSWORD | 資料庫密碼 | QFOts8FlibiI |
|
||||||
|
| HOST | 資料庫主機位址 | mysql.theaken.com |
|
||||||
|
| PORT | 資料庫連接埠 | 33306 |
|
||||||
|
| DATABASE | 資料庫名稱 | db_A001 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 系統設計
|
||||||
|
|
||||||
|
### 4.1 配置管理架構
|
||||||
|
|
||||||
|
```
|
||||||
|
專案根目錄/
|
||||||
|
├── .env # 實際環境變數(不提交)
|
||||||
|
├── .env.example # 環境變數範本(提交)
|
||||||
|
├── .gitignore # 忽略 .env
|
||||||
|
├── config.py # 配置類別
|
||||||
|
└── simple_app.py # 主應用程式
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 配置載入流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 應用程式啟動
|
||||||
|
↓
|
||||||
|
2. load_dotenv() 載入 .env 檔案
|
||||||
|
↓
|
||||||
|
3. 從環境變數讀取 DB_* 參數
|
||||||
|
↓
|
||||||
|
4. 組裝 MySQL 連線字串
|
||||||
|
↓
|
||||||
|
5. 設定 app.config['SQLALCHEMY_DATABASE_URI']
|
||||||
|
↓
|
||||||
|
6. 初始化 SQLAlchemy 與資料庫連線
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 資料模型相容性檢查
|
||||||
|
|
||||||
|
#### 4.3.1 需檢查的資料類型
|
||||||
|
|
||||||
|
| SQLite 類型 | MySQL 類型 | 相容性 |
|
||||||
|
|------------|-----------|--------|
|
||||||
|
| INTEGER | INT | ✅ 相容 |
|
||||||
|
| TEXT | TEXT | ✅ 相容 |
|
||||||
|
| VARCHAR(n) | VARCHAR(n) | ✅ 相容 |
|
||||||
|
| BOOLEAN | TINYINT(1) | ✅ 相容 |
|
||||||
|
| DATETIME | DATETIME | ✅ 相容 |
|
||||||
|
|
||||||
|
#### 4.3.2 現有模型清單
|
||||||
|
|
||||||
|
需確認以下模型的欄位類型與 MySQL 相容:
|
||||||
|
|
||||||
|
1. **User**
|
||||||
|
- id (Integer)
|
||||||
|
- username (String 80)
|
||||||
|
- email (String 120)
|
||||||
|
- full_name (String 100)
|
||||||
|
- department (String 50)
|
||||||
|
- position (String 50)
|
||||||
|
- employee_id (String 50)
|
||||||
|
- password_hash (String 255)
|
||||||
|
- is_active (Boolean)
|
||||||
|
- created_at (DateTime)
|
||||||
|
|
||||||
|
2. **Capability**
|
||||||
|
- id (Integer)
|
||||||
|
- name (String 100)
|
||||||
|
- l1_description (Text)
|
||||||
|
- l2_description (Text)
|
||||||
|
- l3_description (Text)
|
||||||
|
- l4_description (Text)
|
||||||
|
- l5_description (Text)
|
||||||
|
- is_active (Boolean)
|
||||||
|
|
||||||
|
3. **DepartmentCapability**
|
||||||
|
- id (Integer)
|
||||||
|
- department (String 50)
|
||||||
|
- capability_id (Integer, ForeignKey)
|
||||||
|
- created_at (DateTime)
|
||||||
|
|
||||||
|
4. **Assessment**
|
||||||
|
- id (Integer)
|
||||||
|
- user_id (Integer, ForeignKey)
|
||||||
|
- department (String 50)
|
||||||
|
- position (String 50)
|
||||||
|
- employee_name (String 100)
|
||||||
|
- assessment_data (Text)
|
||||||
|
- created_at (DateTime)
|
||||||
|
|
||||||
|
5. **StarFeedback**
|
||||||
|
- id (Integer)
|
||||||
|
- evaluator_name (String 100)
|
||||||
|
- evaluatee_name (String 100)
|
||||||
|
- evaluatee_department (String 50)
|
||||||
|
- evaluatee_position (String 50)
|
||||||
|
- situation (Text)
|
||||||
|
- task (Text)
|
||||||
|
- action (Text)
|
||||||
|
- result (Text)
|
||||||
|
- score (Integer)
|
||||||
|
- points_earned (Integer)
|
||||||
|
- feedback_date (Date)
|
||||||
|
- created_at (DateTime)
|
||||||
|
|
||||||
|
6. **EmployeePoint**
|
||||||
|
- id (Integer)
|
||||||
|
- employee_name (String 100)
|
||||||
|
- department (String 50)
|
||||||
|
- position (String 50)
|
||||||
|
- total_points (Integer)
|
||||||
|
- monthly_points (Integer)
|
||||||
|
|
||||||
|
**檢查結果:** ✅ 所有模型欄位類型與 MySQL 完全相容,無需修改
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 實作設計
|
||||||
|
|
||||||
|
### 5.1 檔案修改清單
|
||||||
|
|
||||||
|
#### 5.1.1 新建檔案
|
||||||
|
|
||||||
|
1. **`.env`** - 環境變數檔案(不提交至 Git)
|
||||||
|
```env
|
||||||
|
DB_HOST=mysql.theaken.com
|
||||||
|
DB_PORT=33306
|
||||||
|
DB_NAME=db_A001
|
||||||
|
DB_USER=A001
|
||||||
|
DB_PASSWORD=QFOts8FlibiI
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **`.env.example`** - 環境變數範本(提交至 Git)
|
||||||
|
```env
|
||||||
|
DB_HOST=your_mysql_host
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_NAME=your_database_name
|
||||||
|
DB_USER=your_database_user
|
||||||
|
DB_PASSWORD=your_database_password
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **`.gitignore`** - Git 忽略規則
|
||||||
|
```
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Python cache
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
|
||||||
|
# Database
|
||||||
|
instance/
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
|
||||||
|
# Migrations (optional, 根據團隊決定是否提交)
|
||||||
|
# migrations/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.1.2 修改檔案
|
||||||
|
|
||||||
|
1. **`requirements.txt`** - 新增依賴
|
||||||
|
```txt
|
||||||
|
Flask==2.3.3
|
||||||
|
Flask-SQLAlchemy==3.0.5
|
||||||
|
Flask-CORS==4.0.0
|
||||||
|
Flask-Migrate==4.0.5 # 新增:資料庫遷移工具
|
||||||
|
PyMySQL==1.1.0 # 已存在,確認保留
|
||||||
|
python-dotenv==1.0.0 # 已存在,確認保留
|
||||||
|
# ... 其他依賴
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **`config.py`** - 修改配置類別
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-for-testing-only'
|
||||||
|
|
||||||
|
# MySQL 資料庫配置
|
||||||
|
DB_HOST = os.environ.get('DB_HOST', 'localhost')
|
||||||
|
DB_PORT = os.environ.get('DB_PORT', '3306')
|
||||||
|
DB_NAME = os.environ.get('DB_NAME', 'partner_alignment')
|
||||||
|
DB_USER = os.environ.get('DB_USER', 'root')
|
||||||
|
DB_PASSWORD = os.environ.get('DB_PASSWORD', '')
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URI = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
SQLALCHEMY_ECHO = False # 是否顯示 SQL 語句
|
||||||
|
|
||||||
|
# ... 其他配置
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **`simple_app.py`** - 修改資料庫初始化
|
||||||
|
|
||||||
|
**方案 A:使用 config.py(推薦)**
|
||||||
|
```python
|
||||||
|
from flask import Flask, render_template, request, jsonify
|
||||||
|
from flask_cors import CORS
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_migrate import Migrate # 新增
|
||||||
|
from config import Config # 新增
|
||||||
|
|
||||||
|
# 創建 Flask 應用程式
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# 從配置類載入設定
|
||||||
|
app.config.from_object(Config)
|
||||||
|
|
||||||
|
# 初始化擴展
|
||||||
|
db = SQLAlchemy(app)
|
||||||
|
migrate = Migrate(app, db) # 新增
|
||||||
|
CORS(app, origins=Config.CORS_ORIGINS)
|
||||||
|
|
||||||
|
# ... 資料模型定義(不變)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
with app.app_context():
|
||||||
|
# 使用 Flask-Migrate 初始化資料庫(取代 db.create_all())
|
||||||
|
# db.create_all() # 移除
|
||||||
|
print("資料庫連線成功!")
|
||||||
|
print(f"連線至: {app.config['SQLALCHEMY_DATABASE_URI']}")
|
||||||
|
|
||||||
|
# 不再需要手動創建表,改用 Flask-Migrate
|
||||||
|
# db.create_all()
|
||||||
|
|
||||||
|
# 創建樣本數據
|
||||||
|
create_sample_data()
|
||||||
|
|
||||||
|
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||||
|
```
|
||||||
|
|
||||||
|
**方案 B:直接修改(不推薦,但若維持現有結構)**
|
||||||
|
```python
|
||||||
|
from flask import Flask, render_template, request, jsonify
|
||||||
|
from flask_cors import CORS
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_migrate import Migrate # 新增
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import os
|
||||||
|
|
||||||
|
load_dotenv() # 新增
|
||||||
|
|
||||||
|
# 創建 Flask 應用程式
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# 從環境變數讀取資料庫設定
|
||||||
|
DB_HOST = os.environ.get('DB_HOST', 'mysql.theaken.com')
|
||||||
|
DB_PORT = os.environ.get('DB_PORT', '33306')
|
||||||
|
DB_NAME = os.environ.get('DB_NAME', 'db_A001')
|
||||||
|
DB_USER = os.environ.get('DB_USER', 'A001')
|
||||||
|
DB_PASSWORD = os.environ.get('DB_PASSWORD', 'QFOts8FlibiI')
|
||||||
|
|
||||||
|
# 配置
|
||||||
|
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-secret-key-for-testing')
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = f'mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}'
|
||||||
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
|
|
||||||
|
# 初始化擴展
|
||||||
|
db = SQLAlchemy(app)
|
||||||
|
migrate = Migrate(app, db) # 新增
|
||||||
|
CORS(app, origins=['http://localhost:5000', 'http://127.0.0.1:5000'])
|
||||||
|
|
||||||
|
# ... 其餘程式碼(不變)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 資料庫遷移指令
|
||||||
|
|
||||||
|
#### 5.2.1 初始化 Flask-Migrate
|
||||||
|
```bash
|
||||||
|
# 設定 Flask 應用程式環境變數
|
||||||
|
set FLASK_APP=simple_app.py # Windows
|
||||||
|
# export FLASK_APP=simple_app.py # Linux/Mac
|
||||||
|
|
||||||
|
# 初始化遷移目錄
|
||||||
|
flask db init
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.2.2 建立初始遷移
|
||||||
|
```bash
|
||||||
|
# 掃描模型並建立遷移腳本
|
||||||
|
flask db migrate -m "Initial migration from SQLite to MySQL"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.2.3 套用遷移至資料庫
|
||||||
|
```bash
|
||||||
|
# 在資料庫建立資料表
|
||||||
|
flask db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.2.4 遷移後續變更
|
||||||
|
```bash
|
||||||
|
# 當模型變更時
|
||||||
|
flask db migrate -m "Description of changes"
|
||||||
|
flask db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 資料遷移策略
|
||||||
|
|
||||||
|
#### 5.3.1 SQLite 至 MySQL 資料轉移
|
||||||
|
|
||||||
|
**方法一:使用 SQLAlchemy 直接轉移(適用少量資料)**
|
||||||
|
```python
|
||||||
|
# 匯出 SQLite 資料
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
# SQLite 連線
|
||||||
|
sqlite_engine = create_engine('sqlite:///instance/partner_alignment.db')
|
||||||
|
|
||||||
|
# MySQL 連線
|
||||||
|
mysql_engine = create_engine('mysql+pymysql://USER:PASSWORD@HOST:PORT/DATABASE')
|
||||||
|
|
||||||
|
# 匯出各表資料
|
||||||
|
tables = ['user', 'capability', 'assessment', 'star_feedback', 'employee_point']
|
||||||
|
|
||||||
|
for table in tables:
|
||||||
|
df = pd.read_sql_table(table, sqlite_engine)
|
||||||
|
df.to_sql(table, mysql_engine, if_exists='append', index=False)
|
||||||
|
print(f"匯入 {table} 完成")
|
||||||
|
```
|
||||||
|
|
||||||
|
**方法二:使用 mysqldump 和匯入工具(適用大量資料)**
|
||||||
|
|
||||||
|
**方法三:重新初始化測試資料(適用開發/測試)**
|
||||||
|
- 直接使用 `create_sample_data()` 函數建立測試資料
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 部署步驟
|
||||||
|
|
||||||
|
### 6.1 開發環境準備
|
||||||
|
|
||||||
|
#### 步驟 1:安裝依賴
|
||||||
|
```bash
|
||||||
|
# 安裝新增的依賴
|
||||||
|
pip install Flask-Migrate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 步驟 2:建立環境變數檔案
|
||||||
|
```bash
|
||||||
|
# 複製範本
|
||||||
|
copy .env.example .env # Windows
|
||||||
|
# cp .env.example .env # Linux/Mac
|
||||||
|
|
||||||
|
# 編輯 .env 檔案,填入實際資料庫連線資訊
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 步驟 3:確認資料庫連線
|
||||||
|
```bash
|
||||||
|
# 測試資料庫連線
|
||||||
|
python -c "from sqlalchemy import create_engine; engine = create_engine('mysql+pymysql://A001:QFOts8FlibiI@mysql.theaken.com:33306/db_A001'); engine.connect(); print('連線成功')"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 步驟 4:初始化資料庫遷移
|
||||||
|
```bash
|
||||||
|
# 設定應用程式
|
||||||
|
set FLASK_APP=simple_app.py # Windows
|
||||||
|
# export FLASK_APP=simple_app.py # Linux/Mac
|
||||||
|
|
||||||
|
# 初始化遷移目錄
|
||||||
|
flask db init
|
||||||
|
|
||||||
|
# 建立初始遷移
|
||||||
|
flask db migrate -m "Initial migration to MySQL"
|
||||||
|
|
||||||
|
# 執行遷移
|
||||||
|
flask db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 步驟 4:啟動應用程式
|
||||||
|
```bash
|
||||||
|
python simple_app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 生產環境部署
|
||||||
|
|
||||||
|
#### 步驟 1:伺服器準備
|
||||||
|
```bash
|
||||||
|
# 安裝 MySQL 客戶端工具
|
||||||
|
# Windows: 下載 MySQL Installer
|
||||||
|
# Linux: sudo apt-get install mysql-client
|
||||||
|
# Mac: brew install mysql-client
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 步驟 2:環境變數設定
|
||||||
|
```bash
|
||||||
|
# 在伺服器上建立 .env 檔案
|
||||||
|
# 使用生產環境的資料庫憑證
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 步驟 3:資料庫遷移
|
||||||
|
```bash
|
||||||
|
# 執行遷移(僅首次部署)
|
||||||
|
flask db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 步驟 4:設定反向代理(可選)
|
||||||
|
```nginx
|
||||||
|
# nginx.conf
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:5000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 測試計畫
|
||||||
|
|
||||||
|
### 7.1 單元測試
|
||||||
|
|
||||||
|
#### Test-01: 資料庫連線測試
|
||||||
|
```python
|
||||||
|
def test_database_connection():
|
||||||
|
"""測試資料庫連線是否成功"""
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
engine = create_engine(Config.SQLALCHEMY_DATABASE_URI)
|
||||||
|
connection = engine.connect()
|
||||||
|
assert connection is not None
|
||||||
|
connection.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Test-02: 環境變數載入測試
|
||||||
|
```python
|
||||||
|
def test_env_loading():
|
||||||
|
"""測試環境變數是否正確載入"""
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
assert Config.DB_HOST == 'mysql.theaken.com'
|
||||||
|
assert Config.DB_PORT == '33306'
|
||||||
|
assert Config.DB_NAME == 'db_A001'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Test-03: 資料模型建立測試
|
||||||
|
```python
|
||||||
|
def test_model_creation():
|
||||||
|
"""測試所有資料模型是否正確建立"""
|
||||||
|
with app.app_context():
|
||||||
|
# 檢查所有表是否存在
|
||||||
|
inspector = inspect(db.engine)
|
||||||
|
tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
expected_tables = ['user', 'capability', 'assessment',
|
||||||
|
'star_feedback', 'employee_point']
|
||||||
|
|
||||||
|
for table in expected_tables:
|
||||||
|
assert table in tables
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 整合測試
|
||||||
|
|
||||||
|
#### Test-04: API 端點資料庫操作測試
|
||||||
|
```python
|
||||||
|
def test_login_with_mysql():
|
||||||
|
"""測試登入功能是否正常運作"""
|
||||||
|
response = client.post('/api/auth/login', json={
|
||||||
|
'username': 'admin',
|
||||||
|
'password': 'admin123'
|
||||||
|
})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'access_token' in response.json
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Test-05: 資料寫入測試
|
||||||
|
```python
|
||||||
|
def test_create_assessment():
|
||||||
|
"""測試建立評估記錄"""
|
||||||
|
response = client.post('/api/assessments', json={
|
||||||
|
'department': 'IT',
|
||||||
|
'position': 'Engineer',
|
||||||
|
'assessment_data': {}
|
||||||
|
})
|
||||||
|
assert response.status_code == 201
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 效能測試
|
||||||
|
|
||||||
|
#### Test-06: 連線池測試
|
||||||
|
- 測試多個同時連線是否正常
|
||||||
|
- 驗證連線池配置
|
||||||
|
|
||||||
|
#### Test-07: 查詢效能測試
|
||||||
|
- 比較 SQLite 與 MySQL 的查詢效能
|
||||||
|
- 驗證複雜查詢的執行時間
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 風險評估
|
||||||
|
|
||||||
|
### 8.1 技術風險
|
||||||
|
|
||||||
|
| 風險 | 影響 | 機率 | 緩解措施 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| 資料庫連線失敗 | 高 | 中 | 實作重試機制與連線池 |
|
||||||
|
| 資料遷移資料遺失 | 高 | 低 | 完整備份與測試環境驗證 |
|
||||||
|
| 效能不如預期 | 中 | 低 | 建立索引與查詢優化 |
|
||||||
|
| 相容性問題 | 中 | 低 | 完整相容性測試 |
|
||||||
|
|
||||||
|
### 8.2 營運風險
|
||||||
|
|
||||||
|
| 風險 | 影響 | 機率 | 緩解措施 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| 服務中斷 | 高 | 低 | 維護窗口與回滾計畫 |
|
||||||
|
| 資料不一致 | 高 | 低 | 交易控制與驗證 |
|
||||||
|
| 權限問題 | 中 | 中 | 預先測試使用者權限 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 回滾計畫
|
||||||
|
|
||||||
|
### 9.1 回滾條件
|
||||||
|
- 資料庫連線持續失敗
|
||||||
|
- 資料遷移造成資料損壞
|
||||||
|
- 效能嚴重下降
|
||||||
|
|
||||||
|
### 9.2 回滾步驟
|
||||||
|
```bash
|
||||||
|
# 1. 停止應用程式
|
||||||
|
# Ctrl+C 或 systemctl stop your-app
|
||||||
|
|
||||||
|
# 2. 恢復原 SQLite 配置
|
||||||
|
# 修改 simple_app.py 中的資料庫連線字串
|
||||||
|
|
||||||
|
# 3. 確認 SQLite 資料庫完整
|
||||||
|
# 檢查 instance/partner_alignment.db
|
||||||
|
|
||||||
|
# 4. 重新啟動應用程式
|
||||||
|
python simple_app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 文件交付清單
|
||||||
|
|
||||||
|
### 10.1 技術文件
|
||||||
|
- [ ] 本 SDD 文件
|
||||||
|
- [ ] 修改後的 `config.py`
|
||||||
|
- [ ] 修改後的 `simple_app.py`
|
||||||
|
- [ ] 新建的 `.env.example`
|
||||||
|
- [ ] 新建的 `.gitignore`
|
||||||
|
- [ ] 更新後的 `requirements.txt`
|
||||||
|
|
||||||
|
### 10.2 作業文件
|
||||||
|
- [ ] 部署指南(Deployment Guide)
|
||||||
|
- [ ] 資料庫連線設定說明
|
||||||
|
- [ ] 環境變數配置說明
|
||||||
|
- [ ] 故障排除手冊
|
||||||
|
|
||||||
|
### 10.3 測試文件
|
||||||
|
- [ ] 測試計劃
|
||||||
|
- [ ] 測試結果報告
|
||||||
|
- [ ] 效能測試報告
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 時程規劃
|
||||||
|
|
||||||
|
### 階段 1:準備階段(預計 1 天)
|
||||||
|
- 建立環境變數檔案
|
||||||
|
- 更新 requirements.txt
|
||||||
|
- 安裝依賴套件
|
||||||
|
- 測試資料庫連線
|
||||||
|
|
||||||
|
### 階段 2:程式修改(預計 0.5 天)
|
||||||
|
- 修改 config.py
|
||||||
|
- 修改 simple_app.py
|
||||||
|
- 建立 .env.example 和 .gitignore
|
||||||
|
|
||||||
|
### 階段 3:資料庫遷移(預計 0.5 天)
|
||||||
|
- 初始化 Flask-Migrate
|
||||||
|
- 建立遷移腳本
|
||||||
|
- 執行資料庫升級
|
||||||
|
|
||||||
|
### 階段 4:測試驗證(預計 1 天)
|
||||||
|
- 單元測試
|
||||||
|
- 整合測試
|
||||||
|
- 效能測試
|
||||||
|
- 使用者驗收測試
|
||||||
|
|
||||||
|
### 階段 5:部署上線(預計 0.5 天)
|
||||||
|
- 生產環境部署
|
||||||
|
- 監控與驗證
|
||||||
|
- 文件更新
|
||||||
|
|
||||||
|
**總計:約 3.5 個工作天**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 確認事項
|
||||||
|
|
||||||
|
### 12.1 需要確認的資訊
|
||||||
|
|
||||||
|
**資料庫連線資訊已提供:**
|
||||||
|
- ✅ DB_HOST = mysql.theaken.com
|
||||||
|
- ✅ DB_PORT = 33306
|
||||||
|
- ✅ DB_NAME = db_A001
|
||||||
|
- ✅ DB_USER = A001
|
||||||
|
- ✅ DB_PASSWORD = QFOts8FlibiI
|
||||||
|
|
||||||
|
**資料庫權限需求:**
|
||||||
|
- [ ] 確認 A001 使用者具有 CREATE TABLE 權限
|
||||||
|
- [ ] 確認 A001 使用者具有 CREATE INDEX 權限
|
||||||
|
- [ ] 確認 A001 使用者具有 INSERT, UPDATE, DELETE, SELECT 權限
|
||||||
|
- [ ] 確認 db_A001 資料庫已建立(或具有建立權限)
|
||||||
|
|
||||||
|
### 12.2 需要決策的事項
|
||||||
|
|
||||||
|
1. **是否遷移現有資料?**
|
||||||
|
- 選項 A:直接使用測試資料(快速)
|
||||||
|
- 選項 B:從 SQLite 遷移現有資料(完整)
|
||||||
|
|
||||||
|
2. **Flask-Migrate 遷移目錄是否提交?**
|
||||||
|
- 選項 A:提交至 Git(團隊協作)
|
||||||
|
- 選項 B:不提交(僅本地開發)
|
||||||
|
|
||||||
|
3. **Config 方案選擇?**
|
||||||
|
- 選項 A:使用 config.py(模組化,推薦)
|
||||||
|
- 選項 B:直接修改 simple_app.py(簡單,不推薦)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附錄 A:資料庫連線字串範例
|
||||||
|
|
||||||
|
### A.1 完整連線字串
|
||||||
|
```
|
||||||
|
mysql+pymysql://A001:QFOts8FlibiI@mysql.theaken.com:33306/db_A001
|
||||||
|
```
|
||||||
|
|
||||||
|
### A.2 帶參數的連線字串
|
||||||
|
```
|
||||||
|
mysql+pymysql://USER:PASSWORD@HOST:PORT/DATABASE?charset=utf8mb4&use_unicode=1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附錄 B:常見錯誤與解決方案
|
||||||
|
|
||||||
|
### B.1 連線錯誤
|
||||||
|
|
||||||
|
**錯誤:Access denied for user**
|
||||||
|
```
|
||||||
|
解決:檢查 DB_USER 和 DB_PASSWORD 是否正確
|
||||||
|
```
|
||||||
|
|
||||||
|
**錯誤:Can't connect to MySQL server**
|
||||||
|
```
|
||||||
|
解決:
|
||||||
|
1. 檢查 DB_HOST 和 DB_PORT 是否正確
|
||||||
|
2. 確認防火牆設定
|
||||||
|
3. 確認 MySQL 服務是否運行
|
||||||
|
```
|
||||||
|
|
||||||
|
### B.2 遷移錯誤
|
||||||
|
|
||||||
|
**錯誤:Target database is not up to date**
|
||||||
|
```
|
||||||
|
解決:執行 flask db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
**錯誤:Multiple heads detected**
|
||||||
|
```
|
||||||
|
解決:flask db merge heads
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附錄 C:效能優化建議
|
||||||
|
|
||||||
|
### C.1 索引建立
|
||||||
|
建議在以下欄位建立索引:
|
||||||
|
- `User.username` - 登入查詢
|
||||||
|
- `User.email` - 登入查詢
|
||||||
|
- `Assessment.user_id` - 關聯查詢
|
||||||
|
- `StarFeedback.evaluatee_name` - 排名查詢
|
||||||
|
- `EmployeePoint.total_points` - 排序查詢
|
||||||
|
|
||||||
|
### C.2 連線池配置
|
||||||
|
```python
|
||||||
|
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
|
||||||
|
'pool_size': 10,
|
||||||
|
'pool_recycle': 3600,
|
||||||
|
'pool_pre_ping': True
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件簽核
|
||||||
|
|
||||||
|
| 角色 | 姓名 | 簽名 | 日期 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 專案經理 | | | |
|
||||||
|
| 技術主管 | | | |
|
||||||
|
| 開發人員 | | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文件版本歷史**
|
||||||
|
|
||||||
|
| 版本 | 日期 | 修改內容 | 修改人 |
|
||||||
|
|------|------|----------|--------|
|
||||||
|
| 1.0 | 2025-01-28 | 初始版本 | 資深全端工程師 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 總結
|
||||||
|
|
||||||
|
本 SDD 文件詳細說明了將夥伴對齊系統從 SQLite 遷移至 MySQL 的完整方案,包含:
|
||||||
|
|
||||||
|
1. ✅ 技術架構設計
|
||||||
|
2. ✅ 詳細實作步驟
|
||||||
|
3. ✅ 配置管理方案
|
||||||
|
4. ✅ 資料遷移策略
|
||||||
|
5. ✅ 測試計畫
|
||||||
|
6. ✅ 風險評估與回滾計畫
|
||||||
|
7. ✅ 部署指南
|
||||||
|
|
||||||
|
**待確認項目:**
|
||||||
|
- 資料庫使用者權限
|
||||||
|
- 是否遷移現有資料
|
||||||
|
- 使用 Config 方案 A 或 B
|
||||||
|
|
||||||
|
**下一步行動:**
|
||||||
|
請審閱本文件並確認需求後,即可開始實作。
|
||||||
Reference in New Issue
Block a user