deliver: 交付收口(引擎代为提交)
This commit is contained in:
parent
7f0f5609d3
commit
ad744aaf06
@ -5,9 +5,7 @@
|
||||
DELIMITER $$
|
||||
|
||||
-- append-only 断言过程:非白名单操作一律 SIGNAL 拒绝
|
||||
DROP PROCEDURE IF EXISTS `pbl_assert_append_only`;
|
||||
|
||||
CREATE$$
|
||||
DROP PROCEDURE IF EXISTS `pbl_assert_append_only`$$
|
||||
|
||||
CREATE PROCEDURE `pbl_assert_append_only`(
|
||||
IN p_table VARCHAR(64), IN p_priv VARCHAR(16), IN p_detail VARCHAR(255))
|
||||
@ -22,13 +20,11 @@ BEGIN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'PBL_APPEND_ONLY_DENIED',
|
||||
MYSQL_ERRNO = 45001;
|
||||
END;
|
||||
END$$
|
||||
|
||||
-- BEFORE UPDATE 护栏:把违规语句原文带进错误消息,便于定位调用方
|
||||
DROP TRIGGER IF EXISTS `trg_pbl_runtime_event_append_only_upd`$$
|
||||
|
||||
CREATE$$
|
||||
|
||||
CREATE TRIGGER `trg_pbl_runtime_event_append_only_upd`
|
||||
BEFORE UPDATE ON `pbl_runtime_event` FOR EACH ROW
|
||||
BEGIN
|
||||
@ -50,13 +46,11 @@ BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PBL_APPEND_ONLY_DENIED_UPDATE',
|
||||
MYSQL_ERRNO = 45002;
|
||||
END IF;
|
||||
END;
|
||||
END$$
|
||||
|
||||
-- BEFORE DELETE 护栏
|
||||
DROP TRIGGER IF EXISTS `trg_pbl_runtime_event_append_only_del`$$
|
||||
|
||||
CREATE$$
|
||||
|
||||
CREATE TRIGGER `trg_pbl_runtime_event_append_only_del`
|
||||
BEFORE DELETE ON `pbl_runtime_event` FOR EACH ROW
|
||||
BEGIN
|
||||
@ -78,10 +72,10 @@ BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PBL_APPEND_ONLY_DENIED_DELETE',
|
||||
MYSQL_ERRNO = 45003;
|
||||
END IF;
|
||||
END;
|
||||
END$$
|
||||
|
||||
-- 权限层加固(可选,需 DBA 账号执行;app 账号只保留增/查)
|
||||
-- GRANT INSERT, SELECT, UPDATE, DELETE ON `pbls`.`pbl_runtime_event` TO NULL; -- 占位,勿执行
|
||||
-- REVOKE UPDATE, DELETE ON `pbls`.`pbl_runtime_event` FROM 'pbls'@'%'$$
|
||||
-- REVOKE UPDATE, DELETE ON `pbls`.`pbl_runtime_event` FROM 'pbls'@'%';
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
@ -5,9 +5,7 @@
|
||||
DELIMITER $$
|
||||
|
||||
-- 1) 幂等补建未来月分区:pmax 存在时用 REORGANIZE PARTITION 拆出新月份(不丢数据)
|
||||
DROP PROCEDURE IF EXISTS `pbl_runtime_event_ensure_partitions`;
|
||||
|
||||
CREATE$$
|
||||
DROP PROCEDURE IF EXISTS `pbl_runtime_event_ensure_partitions`$$
|
||||
|
||||
CREATE PROCEDURE `pbl_runtime_event_ensure_partitions`(IN p_months_ahead INT)
|
||||
MODIFIES SQL DATA
|
||||
@ -49,13 +47,11 @@ BEGIN
|
||||
END IF;
|
||||
END LOOP;
|
||||
CLOSE cur;
|
||||
END;
|
||||
END$$
|
||||
|
||||
-- 2) 到期归档:整月 DROP PARTITION(append-only 表唯一合规的清理手段)
|
||||
DROP PROCEDURE IF EXISTS `pbl_runtime_event_prune_partitions`$$
|
||||
|
||||
CREATE$$
|
||||
|
||||
CREATE PROCEDURE `pbl_runtime_event_prune_partitions`(IN p_keep_months INT)
|
||||
MODIFIES SQL DATA
|
||||
BEGIN
|
||||
@ -82,16 +78,11 @@ BEGIN
|
||||
DEALLOCATE PREPARE pbl_drop_st;
|
||||
END LOOP;
|
||||
CLOSE cur;
|
||||
END;
|
||||
END$$
|
||||
|
||||
-- 3) 每月自动补分区(需 event_scheduler=ON;否则用 cron,见开发说明)
|
||||
-- SET GLOBAL event_scheduler = ON$$
|
||||
|
||||
DROP$$
|
||||
|
||||
DROP EVENT IF EXISTS `ev_pbl_runtime_event_partition_maintain`;
|
||||
|
||||
CREATE$$
|
||||
-- SET GLOBAL event_scheduler = ON;
|
||||
DROP EVENT IF EXISTS `ev_pbl_runtime_event_partition_maintain`$$
|
||||
|
||||
CREATE EVENT IF NOT EXISTS `ev_pbl_runtime_event_partition_maintain`
|
||||
ON SCHEDULE EVERY 1 MONTH
|
||||
|
||||
@ -384,23 +384,140 @@ def full_script(dbname="pbls", app_user="pbls", today=None, months=None):
|
||||
+ migrate_from_legacy_sql())
|
||||
|
||||
|
||||
def _with_delimiter(body):
|
||||
"""把过程/触发器体内语句交给 mysql CLI:整段以 $$ 结束。"""
|
||||
_BLOCK_OPEN = re.compile(r"^BEGIN\b", re.I)
|
||||
_BLOCK_CLOSE = re.compile(r"^END\s*;?\s*$", re.I)
|
||||
_SUB_CLOSE = re.compile(r"^END\s+(IF|LOOP|CASE)\b", re.I)
|
||||
_STMT_HEAD = re.compile(
|
||||
r"^(DROP|CREATE|GRANT|REVOKE|SET|SELECT|CALL|ALTER|WITH|INSERT|UPDATE|DELETE|START)\b", re.I)
|
||||
# 一个 $$ 段内不应再出现的「另一条 DDL 语句头」——出现即说明两条语句被黏在了一起
|
||||
_INLINE_DDL_HEAD = re.compile(
|
||||
r"^\s*(DROP|CREATE)\s+(PROCEDURE|TRIGGER|EVENT|TABLE|FUNCTION)\b", re.I)
|
||||
# 裸关键字残缺行:`CREATE$$` / `DROP$$`(QC #12 的致命产物形态)
|
||||
_BARE_TOKEN = re.compile(r"^(CREATE|DROP|ALTER|GRANT|REVOKE)\s*\$\$\s*$", re.I)
|
||||
|
||||
|
||||
def _with_delimiter(body, delim="$$"):
|
||||
"""把过程/触发器/EVENT 正文按语句切分,逐条以 $$ 收尾,交给 mysql CLI(DELIMITER)执行。
|
||||
|
||||
QC #12 修复(致命缺陷:迁移资产整体不可执行):
|
||||
旧实现 `re.split(r"(?<=;)\n(?=(DROP|CREATE|...))", body)` 的前瞻里带**捕获组**,
|
||||
re.split 会把捕获到的文本('CREATE' / 'DROP')作为独立元素插进结果列表;这些孤立 token
|
||||
随后被当成一条完整语句,拼成裸 `CREATE$$` / `DROP$$` 行写进 .sql。在 `DELIMITER $$` 下
|
||||
服务端把 `CREATE` 当独立语句解析 → ERROR 1064,02/03/canonical 三个文件全部无法执行。
|
||||
另外旧实现把 `DROP PROCEDURE/TRIGGER/EVENT` 留在 `;` 结尾,而 DELIMITER 已改成 `$$`,
|
||||
客户端只认 `$$` 为语句结束符,`;` 结尾的 DROP 会与下一条 CREATE 黏成一条语句。
|
||||
现改为按行扫描 + BEGIN/END 嵌套深度判定语句边界(不再使用带捕获组的 re.split):
|
||||
· 每条语句去掉结尾 `;` 后以 `$$` 结束(含 DROP ... IF EXISTS);
|
||||
· `--` 注释行原样保留、不加 `$$`(否则 `$$` 被行注释吞掉,语句永不结束);
|
||||
· 结构上只可能产出以完整关键字开头的语句段,不可能产出裸 `CREATE`/`DROP`。
|
||||
"""
|
||||
out = []
|
||||
for chunk in re.split(r"(?<=;)\n(?=(DROP|CREATE|GRANT|REVOKE|SELECT|--|SET))", body):
|
||||
c = chunk.strip()
|
||||
if not c:
|
||||
buf = []
|
||||
cmts = []
|
||||
depth = [0]
|
||||
|
||||
def flush():
|
||||
if not buf:
|
||||
if cmts:
|
||||
out.append("\n".join(cmts))
|
||||
del cmts[:]
|
||||
return
|
||||
stmt = re.sub(r";\s*$", "", "\n".join(buf).strip())
|
||||
head = ("\n".join(cmts) + "\n") if cmts else ""
|
||||
out.append(head + stmt + delim)
|
||||
del buf[:]
|
||||
del cmts[:]
|
||||
|
||||
for raw in body.replace("\r\n", "\n").split("\n"):
|
||||
line = raw.rstrip()
|
||||
s = line.strip()
|
||||
if not s:
|
||||
continue
|
||||
if c.startswith("--") or c.startswith("SET ") or c.startswith("GRANT") or c.startswith("REVOKE"):
|
||||
out.append(c if c.endswith(";") else c + ";")
|
||||
elif c.startswith("DROP PROCEDURE") or c.startswith("DROP TRIGGER") or c.startswith("DROP EVENT"):
|
||||
out.append(c if c.endswith(";") else c + ";")
|
||||
else:
|
||||
stmt = c.rstrip(";")
|
||||
out.append(stmt + "$$")
|
||||
if not buf:
|
||||
if s.startswith("--") or s.startswith("#") or not _STMT_HEAD.match(s):
|
||||
cmts.append(line)
|
||||
continue
|
||||
if _BLOCK_OPEN.match(s):
|
||||
depth[0] += 1
|
||||
elif _BLOCK_CLOSE.match(s) and depth[0] > 0:
|
||||
depth[0] -= 1
|
||||
buf.append(line)
|
||||
if depth[0] == 0 and s.endswith(";") and not _SUB_CLOSE.match(s):
|
||||
flush()
|
||||
flush()
|
||||
return "\n\n".join(out) + "\n"
|
||||
|
||||
|
||||
def _delimiter_integrity_errors(text, label=""):
|
||||
"""DELIMITER 语句完整性静态断言(QC #16:PASS 要能证明「可执行」,而非只查关键字存在)。
|
||||
|
||||
断言项:
|
||||
1) `DELIMITER x` 与 `DELIMITER ;` 严格配对,文件结束时不残留未闭合块;
|
||||
2) 每个 `$$` 段首个代码行必须是**完整**语句头(关键字 + 对象类型),
|
||||
禁止 `CREATE$$` / `DROP$$` 这类孤立残缺 token(服务端 ERROR 1064);
|
||||
3) 一个 `$$` 段内不得再出现第二条 DDL 语句头(防止 `;` 结尾语句与下一条黏连);
|
||||
4) 未以 `$$` 收尾的残段里若含可执行语句(纯注释尾巴放行)即判失败。
|
||||
返回缺陷描述列表;空列表 = 结构通过。
|
||||
"""
|
||||
errs = []
|
||||
pfx = ("[%s] " % label) if label else ""
|
||||
lines = text.split("\n")
|
||||
delim = [None]
|
||||
seg = [[]]
|
||||
|
||||
def code_of(ls):
|
||||
return [x.strip() for x in ls if x.strip() and not x.strip().startswith("--")
|
||||
and not x.strip().startswith("#")]
|
||||
|
||||
def close_segment():
|
||||
code = code_of(seg[0])
|
||||
if not code:
|
||||
seg[0] = []
|
||||
return
|
||||
d = delim[0] or "$$"
|
||||
first_words = code[0].replace(d, "").split()
|
||||
if len(first_words) < 2:
|
||||
errs.append(pfx + "残缺语句 token(服务端按独立语句解析 → ERROR 1064): %r" % code[0])
|
||||
for x in code[1:]:
|
||||
if _INLINE_DDL_HEAD.match(x):
|
||||
errs.append(pfx + "一个 %s 段内黏连了多条 DDL 语句: %r" % (d, x.strip()))
|
||||
seg[0] = []
|
||||
|
||||
for i, ln in enumerate(lines, 1):
|
||||
s = ln.strip()
|
||||
if _BARE_TOKEN.match(s):
|
||||
errs.append(pfx + "L%d 裸关键字$$ 残缺行: %r" % (i, s))
|
||||
if s.upper().startswith("DELIMITER"):
|
||||
parts = s.split(None, 1)
|
||||
new = parts[1].strip() if len(parts) > 1 else ";"
|
||||
if seg[0]:
|
||||
close_segment()
|
||||
if delim[0] is None:
|
||||
if new == ";":
|
||||
errs.append(pfx + "L%d 出现没有配对的 `DELIMITER ;`" % i)
|
||||
else:
|
||||
delim[0] = new
|
||||
else:
|
||||
if new != ";":
|
||||
errs.append(pfx + "L%d DELIMITER %s 未闭合就切换到 %s"
|
||||
% (i, delim[0], new))
|
||||
delim[0] = new
|
||||
else:
|
||||
delim[0] = None
|
||||
continue
|
||||
if delim[0]:
|
||||
seg[0].append(ln)
|
||||
if s.endswith(delim[0]):
|
||||
close_segment()
|
||||
if seg[0]:
|
||||
errs.append(pfx + "DELIMITER 块末尾存在未以 %s 收尾的可执行残段: %r"
|
||||
% (delim[0] or "$$", code_of(seg[0])[0][:60]))
|
||||
seg[0] = []
|
||||
if delim[0] is not None:
|
||||
errs.append(pfx + "文件结束时 DELIMITER %s 仍未闭合" % delim[0])
|
||||
return errs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 迁移文件产出
|
||||
MIGRATION_FILES = [
|
||||
("20260919_m11b_01_create_pbl_runtime_event.sql",
|
||||
@ -526,6 +643,43 @@ def verify():
|
||||
msgs.append("FAIL DDL 非幂等(缺 IF EXISTS / DROP IF EXISTS)")
|
||||
else:
|
||||
msgs.append("PASS 幂等:IF NOT EXISTS + DROP ... IF EXISTS + 分区存在性判断")
|
||||
|
||||
# 8. DELIMITER 语句完整性(QC #16:PASS 必须能证明「可执行」,不能只查关键字存在性)
|
||||
artifacts = [
|
||||
("01", create_table_sql()),
|
||||
("02", MIGRATION_FILES[1][1]("pbls")),
|
||||
("03", MIGRATION_FILES[2][1]("pbls")),
|
||||
("canonical", full_script()),
|
||||
]
|
||||
struct_errs = []
|
||||
for label, text in artifacts:
|
||||
struct_errs.extend(_delimiter_integrity_errors(text, label))
|
||||
if struct_errs:
|
||||
ok = False
|
||||
for e in struct_errs:
|
||||
msgs.append("FAIL DELIMITER 语句完整性: " + e)
|
||||
else:
|
||||
msgs.append("PASS DELIMITER 语句完整性: 4 个产物 DELIMITER 配对正确、%d 个 $$ 段首关键字完整、"
|
||||
"无裸 CREATE$$/DROP$$ 残缺行"
|
||||
% sum(len(re.findall(r"\$\$\s*$", t, re.M)) for _, t in artifacts))
|
||||
|
||||
# 9. 幂等/结构关键字逐产物覆盖(QC #17:完整陈述,不再跳号回避缺陷)
|
||||
need_map = [
|
||||
("01", ("CREATE TABLE IF NOT EXISTS", "PARTITION BY RANGE COLUMNS",
|
||||
"VALUES LESS THAN (MAXVALUE)")),
|
||||
("02", ("DROP PROCEDURE IF EXISTS", "DROP TRIGGER IF EXISTS",
|
||||
"BEFORE UPDATE", "BEFORE DELETE")),
|
||||
("03", ("DROP PROCEDURE IF EXISTS", "DROP EVENT IF EXISTS", "CREATE EVENT")),
|
||||
]
|
||||
by_label = dict(artifacts)
|
||||
miss_all = []
|
||||
for label, needs in need_map:
|
||||
miss_all.extend("%s:%s" % (label, n) for n in needs if n not in by_label[label])
|
||||
if miss_all:
|
||||
ok = False
|
||||
msgs.append("FAIL 幂等/结构关键字缺失: " + ", ".join(miss_all))
|
||||
else:
|
||||
msgs.append("PASS 幂等关键字逐文件覆盖: 01 建表分区 / 02 过程+双触发器 / 03 双过程+EVENT")
|
||||
return ok, msgs
|
||||
|
||||
|
||||
@ -632,16 +786,21 @@ def _norm_part(name):
|
||||
return text.strip().strip("`").strip('"').strip("'").lower()
|
||||
|
||||
|
||||
def _render(sql_tpl, args=(), placeholders=None):
|
||||
"""渲染 SQL 模板里的**标识符**占位符(表名/分区名),并对 % 转义做 fail-fast 自检。
|
||||
def _render(sql, args=None, placeholders=None):
|
||||
"""SQL 的 % 转义统一出口:可选渲染标识符 + fail-fast 自检(QC #1 的根因处置)。
|
||||
|
||||
约定(QC #1 的根因处置):模板中由 Python 填入的标识符写 %s;要交给 DBAPI 绑参的
|
||||
百分号占位符一律写 %%s,经本函数渲染后变回 %s。渲染后:
|
||||
· 仍残留 '%%' → 转义写错,抛错;
|
||||
· '%s' 个数与预期绑参个数不符 → 抛错。
|
||||
约定:模板中由 Python 侧填入的标识符(表名/分区名)写 %s;要留给 DBAPI 绑参的
|
||||
百分号占位符一律写 %%s,标识符渲染后即变回 %s。
|
||||
· args 不为 None:先执行 sql % args 完成标识符渲染;
|
||||
· args 为 None:视为调用方已完成渲染(模板就地 % 过),本函数只做转义自检。
|
||||
自检两条,任一不满足立即抛 RuntimeError(而不是丢给驱动报
|
||||
"unsupported format character",那会在 verify_db 里被 except 吞成一条模糊失败):
|
||||
· 结果仍残留 '%%' → 转义写错;
|
||||
· '%s' 个数与 placeholders 不符 → 漏写/多写绑参占位符。
|
||||
渲染单独成行、括号显式定界,不再依赖「相邻字面量先拼接、再整体 %」的隐式优先级。
|
||||
"""
|
||||
sql = sql_tpl % tuple(args)
|
||||
if args is not None:
|
||||
sql = sql % tuple(args)
|
||||
if "%%" in sql:
|
||||
raise RuntimeError("SQL 渲染后仍残留 '%%',转义写法有误: " + sql[:200])
|
||||
if placeholders is not None and sql.count("%s") != placeholders:
|
||||
|
||||
@ -60,9 +60,7 @@ DELIMITER $$
|
||||
|
||||
-- ---------------------------------------------------------------- B. append-only
|
||||
-- append-only 断言过程:非白名单操作一律 SIGNAL 拒绝
|
||||
DROP PROCEDURE IF EXISTS `pbl_assert_append_only`;
|
||||
|
||||
CREATE$$
|
||||
DROP PROCEDURE IF EXISTS `pbl_assert_append_only`$$
|
||||
|
||||
CREATE PROCEDURE `pbl_assert_append_only`(
|
||||
IN p_table VARCHAR(64), IN p_priv VARCHAR(16), IN p_detail VARCHAR(255))
|
||||
@ -77,13 +75,11 @@ BEGIN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'PBL_APPEND_ONLY_DENIED',
|
||||
MYSQL_ERRNO = 45001;
|
||||
END;
|
||||
END$$
|
||||
|
||||
-- BEFORE UPDATE 护栏:把违规语句原文带进错误消息,便于定位调用方
|
||||
DROP TRIGGER IF EXISTS `trg_pbl_runtime_event_append_only_upd`$$
|
||||
|
||||
CREATE$$
|
||||
|
||||
CREATE TRIGGER `trg_pbl_runtime_event_append_only_upd`
|
||||
BEFORE UPDATE ON `pbl_runtime_event` FOR EACH ROW
|
||||
BEGIN
|
||||
@ -105,13 +101,11 @@ BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PBL_APPEND_ONLY_DENIED_UPDATE',
|
||||
MYSQL_ERRNO = 45002;
|
||||
END IF;
|
||||
END;
|
||||
END$$
|
||||
|
||||
-- BEFORE DELETE 护栏
|
||||
DROP TRIGGER IF EXISTS `trg_pbl_runtime_event_append_only_del`$$
|
||||
|
||||
CREATE$$
|
||||
|
||||
CREATE TRIGGER `trg_pbl_runtime_event_append_only_del`
|
||||
BEFORE DELETE ON `pbl_runtime_event` FOR EACH ROW
|
||||
BEGIN
|
||||
@ -133,17 +127,15 @@ BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PBL_APPEND_ONLY_DENIED_DELETE',
|
||||
MYSQL_ERRNO = 45003;
|
||||
END IF;
|
||||
END;
|
||||
END$$
|
||||
|
||||
-- 权限层加固(可选,需 DBA 账号执行;app 账号只保留增/查)
|
||||
-- GRANT INSERT, SELECT, UPDATE, DELETE ON `pbls`.`pbl_runtime_event` TO NULL; -- 占位,勿执行
|
||||
-- REVOKE UPDATE, DELETE ON `pbls`.`pbl_runtime_event` FROM 'pbls'@'%'$$
|
||||
-- REVOKE UPDATE, DELETE ON `pbls`.`pbl_runtime_event` FROM 'pbls'@'%';
|
||||
|
||||
-- ---------------------------------------------------------------- C. 分区维护
|
||||
-- 1) 幂等补建未来月分区:pmax 存在时用 REORGANIZE PARTITION 拆出新月份(不丢数据)
|
||||
DROP PROCEDURE IF EXISTS `pbl_runtime_event_ensure_partitions`;
|
||||
|
||||
CREATE$$
|
||||
DROP PROCEDURE IF EXISTS `pbl_runtime_event_ensure_partitions`$$
|
||||
|
||||
CREATE PROCEDURE `pbl_runtime_event_ensure_partitions`(IN p_months_ahead INT)
|
||||
MODIFIES SQL DATA
|
||||
@ -185,13 +177,11 @@ BEGIN
|
||||
END IF;
|
||||
END LOOP;
|
||||
CLOSE cur;
|
||||
END;
|
||||
END$$
|
||||
|
||||
-- 2) 到期归档:整月 DROP PARTITION(append-only 表唯一合规的清理手段)
|
||||
DROP PROCEDURE IF EXISTS `pbl_runtime_event_prune_partitions`$$
|
||||
|
||||
CREATE$$
|
||||
|
||||
CREATE PROCEDURE `pbl_runtime_event_prune_partitions`(IN p_keep_months INT)
|
||||
MODIFIES SQL DATA
|
||||
BEGIN
|
||||
@ -218,16 +208,11 @@ BEGIN
|
||||
DEALLOCATE PREPARE pbl_drop_st;
|
||||
END LOOP;
|
||||
CLOSE cur;
|
||||
END;
|
||||
END$$
|
||||
|
||||
-- 3) 每月自动补分区(需 event_scheduler=ON;否则用 cron,见开发说明)
|
||||
-- SET GLOBAL event_scheduler = ON$$
|
||||
|
||||
DROP$$
|
||||
|
||||
DROP EVENT IF EXISTS `ev_pbl_runtime_event_partition_maintain`;
|
||||
|
||||
CREATE$$
|
||||
-- SET GLOBAL event_scheduler = ON;
|
||||
DROP EVENT IF EXISTS `ev_pbl_runtime_event_partition_maintain`$$
|
||||
|
||||
CREATE EVENT IF NOT EXISTS `ev_pbl_runtime_event_partition_maintain`
|
||||
ON SCHEDULE EVERY 1 MONTH
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user