(updateRoleInputDto);
- roleRepository.Update(entity);
+ var result = roleRepository.Update(entity);
+ if (!result)
+ {
+ return BaseResponseFactory.ConcurrencyConflict();
+ }
return new BaseResponse();
}
catch (Exception ex)
diff --git a/EOM.TSHotelManagement.Service/SystemManagement/SupervisionStatistics/SupervisionStatisticsService.cs b/EOM.TSHotelManagement.Service/SystemManagement/SupervisionStatistics/SupervisionStatisticsService.cs
index 7b545eafd550128eadc2329d7f3051f81c993123..1b370db4567c385fbfd1af5c233af6a8a2c52444 100644
--- a/EOM.TSHotelManagement.Service/SystemManagement/SupervisionStatistics/SupervisionStatisticsService.cs
+++ b/EOM.TSHotelManagement.Service/SystemManagement/SupervisionStatistics/SupervisionStatisticsService.cs
@@ -1,4 +1,4 @@
-/*
+/*
* MIT License
*Copyright (c) 2021 易开元(Easy-Open-Meta)
@@ -120,8 +120,13 @@ namespace EOM.TSHotelManagement.Service
supervisionStatistics.SupervisionAdvice = checkInfo.SupervisionAdvice;
supervisionStatistics.SupervisionStatistician = checkInfo.SupervisionStatistician;
supervisionStatistics.SupervisionProgress = checkInfo.SupervisionProgress;
+ supervisionStatistics.RowVersion = checkInfo.RowVersion ?? 0;
- checkInfoRepository.Update(supervisionStatistics);
+ var updateResult = checkInfoRepository.Update(supervisionStatistics);
+ if (!updateResult)
+ {
+ return BaseResponseFactory.ConcurrencyConflict();
+ }
}
catch (Exception ex)
{
@@ -149,7 +154,8 @@ namespace EOM.TSHotelManagement.Service
};
}
- var supervisionStatistics = checkInfoRepository.GetList(a => input.DelIds.Contains(a.Id));
+ var delIds = DeleteConcurrencyHelper.GetDeleteIds(input);
+ var supervisionStatistics = checkInfoRepository.GetList(a => delIds.Contains(a.Id));
if (!supervisionStatistics.Any())
{
@@ -160,6 +166,11 @@ namespace EOM.TSHotelManagement.Service
};
}
+ if (DeleteConcurrencyHelper.HasDeleteConflict(input, supervisionStatistics, a => a.Id, a => a.RowVersion))
+ {
+ return BaseResponseFactory.ConcurrencyConflict();
+ }
+
// 批量软删除
var result = checkInfoRepository.SoftDeleteRange(supervisionStatistics);
diff --git a/EOM.TSHotelManagement.Service/SystemManagement/VipRule/VipRuleAppService.cs b/EOM.TSHotelManagement.Service/SystemManagement/VipRule/VipRuleAppService.cs
index e50f0aa6bf7f519b547adec6031a722f11b4c39c..2de9ecbc0a179364462ba2ff20184837917b5735 100644
--- a/EOM.TSHotelManagement.Service/SystemManagement/VipRule/VipRuleAppService.cs
+++ b/EOM.TSHotelManagement.Service/SystemManagement/VipRule/VipRuleAppService.cs
@@ -1,4 +1,4 @@
-/*
+/*
* MIT License
*Copyright (c) 2021 易开元(Easy-Open-Meta)
@@ -154,7 +154,8 @@ namespace EOM.TSHotelManagement.Service
};
}
- var vipLevelRules = vipRuleRepository.GetList(a => vipRule.DelIds.Contains(a.Id));
+ var delIds = DeleteConcurrencyHelper.GetDeleteIds(vipRule);
+ var vipLevelRules = vipRuleRepository.GetList(a => delIds.Contains(a.Id));
if (!vipLevelRules.Any())
{
@@ -165,6 +166,11 @@ namespace EOM.TSHotelManagement.Service
};
}
+ if (DeleteConcurrencyHelper.HasDeleteConflict(vipRule, vipLevelRules, a => a.Id, a => a.RowVersion))
+ {
+ return BaseResponseFactory.ConcurrencyConflict();
+ }
+
// 批量软删除
vipRuleRepository.SoftDeleteRange(vipLevelRules);
return new BaseResponse();
@@ -186,11 +192,20 @@ namespace EOM.TSHotelManagement.Service
try
{
var dbVipRule = vipRuleRepository.GetFirst(a => a.Id == vipRule.Id);
+ if (dbVipRule == null)
+ {
+ return new BaseResponse(BusinessStatusCode.NotFound, LocalizationHelper.GetLocalizedString("Vip Rule Not Found", "会员规则未找到"));
+ }
dbVipRule.RuleName = vipRule.RuleName;
dbVipRule.RuleValue = vipRule.RuleValue;
dbVipRule.VipLevelId = vipRule.VipLevelId;
dbVipRule.IsDelete = vipRule.IsDelete;
- vipRuleRepository.Update(dbVipRule);
+ dbVipRule.RowVersion = vipRule.RowVersion ?? 0;
+ var updateResult = vipRuleRepository.Update(dbVipRule);
+ if (!updateResult)
+ {
+ return BaseResponseFactory.ConcurrencyConflict();
+ }
}
catch (Exception ex)
{
diff --git a/EOM.TSHotelManagement.Service/Util/UtilService.cs b/EOM.TSHotelManagement.Service/Util/UtilService.cs
index 57e29a1f39135d153b95e81533aad31b67e6583a..1f04c1e665382f2f9780c89bbde0a7867c515f8c 100644
--- a/EOM.TSHotelManagement.Service/Util/UtilService.cs
+++ b/EOM.TSHotelManagement.Service/Util/UtilService.cs
@@ -1,4 +1,4 @@
-using EOM.TSHotelManagement.Common;
+using EOM.TSHotelManagement.Common;
using EOM.TSHotelManagement.Contract;
using EOM.TSHotelManagement.Data;
using EOM.TSHotelManagement.Domain;
@@ -210,7 +210,8 @@ namespace EOM.TSHotelManagement.Service
};
}
- var operationLogs = operationLogRepository.GetList(a => deleteOperationLogInputDto.DelIds.Contains(a.Id));
+ var delIds = DeleteConcurrencyHelper.GetDeleteIds(deleteOperationLogInputDto);
+ var operationLogs = operationLogRepository.GetList(a => delIds.Contains(a.Id));
if (!operationLogs.Any())
{
@@ -221,6 +222,11 @@ namespace EOM.TSHotelManagement.Service
};
}
+ if (DeleteConcurrencyHelper.HasDeleteConflict(deleteOperationLogInputDto, operationLogs, a => a.Id, a => a.RowVersion))
+ {
+ return BaseResponseFactory.ConcurrencyConflict();
+ }
+
operationLogRepository.Delete(operationLogs);
return new BaseResponse();
}
diff --git a/README.en.md b/README.en.md
index 0c6954942af887d1fc937ba289466bd4d6526f9f..424be9cad3678ff58938caa3da145e12584776a5 100644
--- a/README.en.md
+++ b/README.en.md
@@ -18,6 +18,13 @@ Primarily designed to achieve front-end/back-end separation following the upgrad
## Core Functional Features
+### 0. Account Security Enhancements (TOTP 2FA)
+- **TOTP-based 2FA support**: Staff, administrators, and customers can all enable/disable 2FA.
+- **Recovery code support**: Users can complete login with one-time recovery codes when authenticator access is lost.
+- **Recovery codes returned on first enablement**: `EnableTwoFactor` now returns the initial batch of recovery codes directly to avoid duplicate regeneration from the frontend.
+- **Security alerting**: When a login succeeds via a recovery code, the system attempts to send an email notification before completing the response.
+- **Complete API coverage**: Includes status query, binding info generation, enable/disable, recovery code reset, and remaining count query.
+
### 1. Business Management Modules
- **Room Management**: Supports room status management (Vacant, Occupied, Under Maintenance, Dirty, Reserved), check-in/check-out, room transfers, and configuration (type, pricing).
- **Guest Management**: Guest profile management, account registration/login, membership tier administration.
@@ -125,32 +132,37 @@ This project utilises the SqlSugar framework to support one-click database and t
### Docker Deployment
-The project provides a Dockerfile (alternatively, images can be rapidly built via the `build.ps1` script, provided WSL 2.0 and Hyper-V are enabled locally and Docker Desktop is installed), supporting Docker containerised deployment. The API listens on port 8080 by default.
+The project provides a Dockerfile (alternatively, images can be rapidly built via the `build.ps1` script, provided WSL 2.0 and Hyper-V are enabled locally and Docker Desktop is installed), supporting Docker containerised deployment. The API listens on port 8080 by default.
+To avoid manually maintaining a very long `docker run` command, this repo now includes `docker-compose.yml` and `.env.example`.
+
+```bash
+# 1) Prepare env file
+cp .env.example .env
+
+# Windows PowerShell:
+# Copy-Item .env.example .env
+
+# 2) Edit .env (database connection, JWT key, idempotency policy, mail settings, etc.)
+
+# 3) Start service
+docker compose up -d
+
+# 4) View logs
+docker compose logs -f tshotel-api
+
+# 5) Stop and remove container
+docker compose down
+```
+
+If you still prefer `docker run`, you can shorten it with `--env-file`:
```bash
-# Please modify environment variable parameters according to your actual setup
docker run -d \
--name tshotel-api \
- --health-cmd="curl -f http://localhost:8080/health || exit 1" \
- --health-interval=30s \
- --health-timeout=10s \
- --health-retries=3 \
+ --env-file .env \
-v /app/config:/app/config \
-v /app/keys:/app/keys \
-p 63001:8080 \
- -e ASPNETCORE_ENVIRONMENT=docker \
- -e DefaultDatabase=MariaDB \
- -e MariaDBConnectStr="Server=your_db_host;Database=tshoteldb;User=tshoteldb;Password=your_password;" \
- -e InitializeDatabase=true \
- -e Jwt__Key=your_super_secret_key \
- -e Jwt__ExpiryMinutes=20 \
- -e Mail__Enabled=true \
- -e Mail__Host=smtp.example.com \
- -e Mail__UserName=admin@example.com \
- -e Mail__Password=your_email_password \
- -e Mail__Port=465 \
- -e AllowedOrigins__0=http://localhost:8080 \
- -e AllowedOrigins__1=https://www.yourdomain.com \
yjj6731/tshotel-management-system-api:latest
```
@@ -181,9 +193,25 @@ docker run -d \
|AllowedOrigins__0|Allowed Domain Sites (for Development Environments)|Y|http://localhost:8080|http://localhost:8080|
|AllowedOrigins__1|Allowed domain sites for production environment|Y|https://www.yourdomain.com|https://www.yourdomain.com|
|SoftwareVersion|Software version number for documentation purposes|N|N/A|N/A|
+|JobKeys__0|Quartz Job 1|Y|ReservationExpirationCheckJob|ReservationExpirationCheckJob|
+|JobKeys__1|Quartz Job 2|Y|MailServiceCheckJob|MailServiceCheckJob|
+|JobKeys__2|Quartz Job 3|Y|RedisServiceCheckJob|RedisServiceCheckJob|
+|Redis__Enabled|Enable Redis|N|false|true/false|
+|Redis__ConnectionString|Redis ConnectString|N|N/A|N/A|
+|Redis__DefaultDatabase|Default Database of Redis|N|0|0|
+|Idempotency__Enabled|Enable Idempotency-Key middleware|N|true|true/false|
+|Idempotency__EnforceKey|Require Idempotency-Key for write requests|N|false|true/false|
+|Idempotency__MaxKeyLength|Maximum Idempotency-Key length|N|128|integer >= 16|
+|Idempotency__InProgressTtlSeconds|TTL for in-progress record (seconds)|N|120|30~600|
+|Idempotency__CompletedTtlHours|TTL for completed record (hours)|N|24|1~168|
+|Idempotency__PersistFailureResponse|Persist failed responses (non-2xx)|N|false|true/false|
> ⚠️ **Security Advisory**: In production environments, do not directly pass password-like parameters in plaintext via the `-e` flag. It is recommended to utilise Docker Secrets or environment variable injection tools (such as HashiCorp Vault) for protection.
+## Development Pace
+
+
+
## Acknowledgements
We extend our gratitude to the following outstanding open-source projects:
diff --git a/README.md b/README.md
index b18aed902ff1d30ef7b17878c93f12301fe06f1c..79cae06b4a42029a9cf0b21e94cd9dea5308875d 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-
+
TopskyHotelManagementSystem-WebApi
@@ -18,6 +18,13 @@
## 核心功能特性
+### 0. 账户安全增强(TOTP 2FA)
+- **支持 TOTP 双因子认证**:员工、管理员、客户三类账号均支持开启/关闭 2FA。
+- **支持恢复备用码**:当用户丢失验证器时,可使用一次性备用码完成登录。
+- **首次启用即返回备用码**:`EnableTwoFactor` 成功后会直接返回首批备用码,避免前端重复重置生成。
+- **安全告警**:检测到“备用码登录”后,系统会在登录成功前尝试发送邮件通知用户。
+- **接口能力完整**:支持状态查询、绑定信息生成、启用、关闭、备用码重置与剩余数量查询。
+
### 1. 业务管理模块
- **房间管理**:支持房间状态(空房、已住、维修、脏房、预约)管理,入住、退房、换房,房间配置(类型、价格)。
- **客户管理**:客户档案管理,客户账号注册登录,会员类型管理。
@@ -125,32 +132,37 @@ EOM.TSHotelManagement.Web
### Docker 部署
-项目提供了 Dockerfile(亦可通过build.ps1文件快速构建镜像,前提需确保本地启用WSL2.0以及Hyper-V和安装Docker Desktop),支持 Docker 容器化部署。API 默认监听 8080 端口。
+项目提供了 Dockerfile(亦可通过build.ps1文件快速构建镜像,前提需确保本地启用WSL2.0以及Hyper-V和安装Docker Desktop),支持 Docker 容器化部署。API 默认监听 8080 端口。
+为了避免手写超长 `docker run` 命令,仓库已提供 `docker-compose.yml` + `.env.example`。
+
+```bash
+# 1) 准备环境变量文件
+cp .env.example .env
+
+# Windows PowerShell 可用:
+# Copy-Item .env.example .env
+
+# 2) 按需修改 .env(数据库连接、JWT 密钥、幂等策略、邮箱等)
+
+# 3) 启动
+docker compose up -d
+
+# 4) 查看日志
+docker compose logs -f tshotel-api
+
+# 5) 停止并移除容器
+docker compose down
+```
+
+如果你仍想使用 `docker run`,也可以改成 `--env-file` 方式,命令会短很多:
```bash
-# 请根据实际情况修改环境变量参数
docker run -d \
--name tshotel-api \
- --health-cmd="curl -f http://localhost:8080/health || exit 1" \
- --health-interval=30s \
- --health-timeout=10s \
- --health-retries=3 \
+ --env-file .env \
-v /app/config:/app/config \
-v /app/keys:/app/keys \
-p 63001:8080 \
- -e ASPNETCORE_ENVIRONMENT=docker \
- -e DefaultDatabase=MariaDB \
- -e MariaDBConnectStr="Server=your_db_host;Database=tshoteldb;User=tshoteldb;Password=your_password;" \
- -e InitializeDatabase=true \
- -e Jwt__Key=your_super_secret_key \
- -e Jwt__ExpiryMinutes=20 \
- -e Mail__Enabled=true \
- -e Mail__Host=smtp.example.com \
- -e Mail__UserName=admin@example.com \
- -e Mail__Password=your_email_password \
- -e Mail__Port=465 \
- -e AllowedOrigins__0=http://localhost:8080 \
- -e AllowedOrigins__1=https://www.yourdomain.com \
yjj6731/tshotel-management-system-api:latest
```
@@ -181,9 +193,25 @@ docker run -d \
|AllowedOrigins__0|允许域站点,用于开发环境|Y|http://localhost:8080|http://localhost:8080|
|AllowedOrigins__1|允许域站点,用于生产环境|Y|https://www.yourdomain.com|https://www.yourdomain.com|
|SoftwareVersion|软件版本号,用于标记说明|N|N/A|N/A|
+|JobKeys__0|定时任务1|Y|ReservationExpirationCheckJob|ReservationExpirationCheckJob|
+|JobKeys__1|定时任务2|Y|MailServiceCheckJob|MailServiceCheckJob|
+|JobKeys__2|定时任务3|Y|RedisServiceCheckJob|RedisServiceCheckJob|
+|Redis__Enabled|是否启用Redis服务|N|false|true/false|
+|Redis__ConnectionString|Redis连接字符串|N|N/A|N/A|
+|Redis__DefaultDatabase|默认数据库|N|0|0|
+|Idempotency__Enabled|是否启用幂等键中间件|N|true|true/false|
+|Idempotency__EnforceKey|是否强制写请求必须携带 Idempotency-Key|N|false|true/false|
+|Idempotency__MaxKeyLength|Idempotency-Key 最大长度|N|128|>=16 的整数|
+|Idempotency__InProgressTtlSeconds|处理中记录 TTL(秒)|N|120|30~600|
+|Idempotency__CompletedTtlHours|完成记录 TTL(小时)|N|24|1~168|
+|Idempotency__PersistFailureResponse|是否缓存失败响应(非2xx)|N|false|true/false|
> ⚠️ **安全提醒**:生产环境中请勿直接通过 `-e` 明文传入密码类参数,推荐使用 Docker Secrets 或环境变量注入工具(如 HashiCorp Vault)进行保护。
+## 开发节奏
+
+
+
## 鸣谢
感谢以下优秀的开源项目:
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000000000000000000000000000000000000..004c1b6df6a5a3629530116b8a01cf861c90434a
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,17 @@
+services:
+ tshotel-api:
+ image: ${TSHOTEL_IMAGE:-yjj6731/tshotel-management-system-api:latest}
+ container_name: ${CONTAINER_NAME:-tshotel-api}
+ restart: unless-stopped
+ ports:
+ - "${API_HOST_PORT:-63001}:8080"
+ volumes:
+ - "${APP_CONFIG_DIR:-./docker-data/config}:/app/config"
+ - "${APP_KEYS_DIR:-./docker-data/keys}:/app/keys"
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8080/health", "||", "exit", "1"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ env_file:
+ - .env
diff --git a/version.txt b/version.txt
index f96eaa82dab2fdf85cb4dde18b9e2e6069c90a11..315de5b54a9165b27f0cb117fab775b21812a334 100644
Binary files a/version.txt and b/version.txt differ
diff --git "a/\346\225\260\346\215\256\345\272\223\350\204\232\346\234\254/MariaDB\347\211\210\346\234\254/MDB_patch_add_row_version.sql" "b/\346\225\260\346\215\256\345\272\223\350\204\232\346\234\254/MariaDB\347\211\210\346\234\254/MDB_patch_add_row_version.sql"
new file mode 100644
index 0000000000000000000000000000000000000000..a79e8310bcdac742e28eee6500872d7d1a823817
--- /dev/null
+++ "b/\346\225\260\346\215\256\345\272\223\350\204\232\346\234\254/MariaDB\347\211\210\346\234\254/MDB_patch_add_row_version.sql"
@@ -0,0 +1,74 @@
+SET @schema_name = DATABASE();
+SET SESSION group_concat_max_len = 1024000;
+
+SELECT GROUP_CONCAT(
+ CONCAT(
+ 'ALTER TABLE `', c.table_name, '` ',
+ 'ADD COLUMN `row_version` BIGINT NOT NULL DEFAULT 1 COMMENT ''Row version (optimistic lock)'';'
+ )
+ SEPARATOR ' '
+ )
+INTO @ddl_sql
+FROM information_schema.columns c
+WHERE c.table_schema = @schema_name
+ AND c.column_name = 'delete_mk'
+ AND NOT EXISTS (
+ SELECT 1
+ FROM information_schema.columns x
+ WHERE x.table_schema = c.table_schema
+ AND x.table_name = c.table_name
+ AND x.column_name = 'row_version'
+ );
+
+SET @ddl_sql = IFNULL(@ddl_sql, 'SELECT 1;');
+PREPARE stmt FROM @ddl_sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SELECT GROUP_CONCAT(
+ CONCAT(
+ 'ALTER TABLE `', c.table_name, '` ',
+ 'ADD UNIQUE INDEX `uk_id_row_version` (`id`, `row_version`);'
+ )
+ SEPARATOR ' '
+ )
+INTO @idx_sql
+FROM information_schema.columns c
+WHERE c.table_schema = @schema_name
+ AND c.column_name = 'delete_mk'
+ AND EXISTS (
+ SELECT 1
+ FROM information_schema.columns x
+ WHERE x.table_schema = c.table_schema
+ AND x.table_name = c.table_name
+ AND x.column_name = 'id'
+ )
+ AND EXISTS (
+ SELECT 1
+ FROM information_schema.columns x
+ WHERE x.table_schema = c.table_schema
+ AND x.table_name = c.table_name
+ AND x.column_name = 'row_version'
+ )
+ AND NOT EXISTS (
+ SELECT 1
+ FROM (
+ SELECT s.index_name,
+ SUM(CASE WHEN s.column_name = 'id' THEN 1 ELSE 0 END) AS has_id,
+ SUM(CASE WHEN s.column_name = 'row_version' THEN 1 ELSE 0 END) AS has_row_version,
+ COUNT(*) AS column_count
+ FROM information_schema.statistics s
+ WHERE s.table_schema = c.table_schema
+ AND s.table_name = c.table_name
+ AND s.non_unique = 0
+ GROUP BY s.index_name
+ ) idx
+ WHERE idx.has_id = 1
+ AND idx.has_row_version = 1
+ AND idx.column_count = 2
+ );
+
+SET @idx_sql = IFNULL(@idx_sql, 'SELECT 1;');
+PREPARE stmt FROM @idx_sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
diff --git "a/\346\225\260\346\215\256\345\272\223\350\204\232\346\234\254/MySQL\347\211\210\346\234\254/MDB_patch_add_row_version.sql" "b/\346\225\260\346\215\256\345\272\223\350\204\232\346\234\254/MySQL\347\211\210\346\234\254/MDB_patch_add_row_version.sql"
new file mode 100644
index 0000000000000000000000000000000000000000..a79e8310bcdac742e28eee6500872d7d1a823817
--- /dev/null
+++ "b/\346\225\260\346\215\256\345\272\223\350\204\232\346\234\254/MySQL\347\211\210\346\234\254/MDB_patch_add_row_version.sql"
@@ -0,0 +1,74 @@
+SET @schema_name = DATABASE();
+SET SESSION group_concat_max_len = 1024000;
+
+SELECT GROUP_CONCAT(
+ CONCAT(
+ 'ALTER TABLE `', c.table_name, '` ',
+ 'ADD COLUMN `row_version` BIGINT NOT NULL DEFAULT 1 COMMENT ''Row version (optimistic lock)'';'
+ )
+ SEPARATOR ' '
+ )
+INTO @ddl_sql
+FROM information_schema.columns c
+WHERE c.table_schema = @schema_name
+ AND c.column_name = 'delete_mk'
+ AND NOT EXISTS (
+ SELECT 1
+ FROM information_schema.columns x
+ WHERE x.table_schema = c.table_schema
+ AND x.table_name = c.table_name
+ AND x.column_name = 'row_version'
+ );
+
+SET @ddl_sql = IFNULL(@ddl_sql, 'SELECT 1;');
+PREPARE stmt FROM @ddl_sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SELECT GROUP_CONCAT(
+ CONCAT(
+ 'ALTER TABLE `', c.table_name, '` ',
+ 'ADD UNIQUE INDEX `uk_id_row_version` (`id`, `row_version`);'
+ )
+ SEPARATOR ' '
+ )
+INTO @idx_sql
+FROM information_schema.columns c
+WHERE c.table_schema = @schema_name
+ AND c.column_name = 'delete_mk'
+ AND EXISTS (
+ SELECT 1
+ FROM information_schema.columns x
+ WHERE x.table_schema = c.table_schema
+ AND x.table_name = c.table_name
+ AND x.column_name = 'id'
+ )
+ AND EXISTS (
+ SELECT 1
+ FROM information_schema.columns x
+ WHERE x.table_schema = c.table_schema
+ AND x.table_name = c.table_name
+ AND x.column_name = 'row_version'
+ )
+ AND NOT EXISTS (
+ SELECT 1
+ FROM (
+ SELECT s.index_name,
+ SUM(CASE WHEN s.column_name = 'id' THEN 1 ELSE 0 END) AS has_id,
+ SUM(CASE WHEN s.column_name = 'row_version' THEN 1 ELSE 0 END) AS has_row_version,
+ COUNT(*) AS column_count
+ FROM information_schema.statistics s
+ WHERE s.table_schema = c.table_schema
+ AND s.table_name = c.table_name
+ AND s.non_unique = 0
+ GROUP BY s.index_name
+ ) idx
+ WHERE idx.has_id = 1
+ AND idx.has_row_version = 1
+ AND idx.column_count = 2
+ );
+
+SET @idx_sql = IFNULL(@idx_sql, 'SELECT 1;');
+PREPARE stmt FROM @idx_sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
diff --git "a/\346\225\260\346\215\256\345\272\223\350\204\232\346\234\254/PostgreSQL\347\211\210\346\234\254/PGDB_patch_add_row_version.sql" "b/\346\225\260\346\215\256\345\272\223\350\204\232\346\234\254/PostgreSQL\347\211\210\346\234\254/PGDB_patch_add_row_version.sql"
new file mode 100644
index 0000000000000000000000000000000000000000..e9f2076744bbc62b835e6e63c9d1ac7201e130d9
--- /dev/null
+++ "b/\346\225\260\346\215\256\345\272\223\350\204\232\346\234\254/PostgreSQL\347\211\210\346\234\254/PGDB_patch_add_row_version.sql"
@@ -0,0 +1,23 @@
+DO
+$$
+DECLARE
+ r record;
+BEGIN
+ FOR r IN
+ SELECT c.table_name
+ FROM information_schema.columns c
+ WHERE c.table_schema = current_schema()
+ AND c.column_name = 'delete_mk'
+ AND NOT EXISTS (
+ SELECT 1
+ FROM information_schema.columns x
+ WHERE x.table_schema = c.table_schema
+ AND x.table_name = c.table_name
+ AND x.column_name = 'row_version'
+ )
+ LOOP
+ EXECUTE format('ALTER TABLE %I ADD COLUMN row_version BIGINT NOT NULL DEFAULT 1;', r.table_name);
+ EXECUTE format('COMMENT ON COLUMN %I.row_version IS %L;', r.table_name, 'Row version (optimistic lock)');
+ END LOOP;
+END
+$$;