diff --git a/i18n/en/msg.txt b/i18n/en/msg.txt new file mode 100644 index 0000000..2e04e76 --- /dev/null +++ b/i18n/en/msg.txt @@ -0,0 +1,6 @@ +存储规格: Storage Spec +存储资源: Storage Resource +容量采样: Capacity Sample +计量方式: Meter Mode +计费容量: Billable Capacity +峰值容量: Peak Capacity diff --git a/i18n/jp/msg.txt b/i18n/jp/msg.txt new file mode 100644 index 0000000..eed210a --- /dev/null +++ b/i18n/jp/msg.txt @@ -0,0 +1,6 @@ +存储规格: ストレージ仕様 +存储资源: ストレージリソース +容量采样: 容量サンプリング +计量方式: 計量方式 +计费容量: 課金容量 +峰值容量: ピーク容量 diff --git a/i18n/ko/msg.txt b/i18n/ko/msg.txt new file mode 100644 index 0000000..cc3d89a --- /dev/null +++ b/i18n/ko/msg.txt @@ -0,0 +1,6 @@ +存储规格: 스토리지 사양 +存储资源: 스토리지 리소스 +容量采样: 용량 샘플링 +计量方式: 계량 방식 +计费容量: 과금 용량 +峰值容量: 피크 용량 diff --git a/i18n/zh/msg.txt b/i18n/zh/msg.txt new file mode 100644 index 0000000..a9a0e19 --- /dev/null +++ b/i18n/zh/msg.txt @@ -0,0 +1,6 @@ +存储规格: 存储规格 +存储资源: 存储资源 +容量采样: 容量采样 +计量方式: 计量方式 +计费容量: 计费容量 +峰值容量: 峰值容量 diff --git a/init/data.json b/init/data.json new file mode 100644 index 0000000..e2d42dc --- /dev/null +++ b/init/data.json @@ -0,0 +1,36 @@ +{ + "storres_spec": [ + { + "spec_code": "WS-STORAGE-STD", + "spec_name": "工作空间标准存储", + "storage_class": "standard", + "min_gb": 1, + "step_gb": 1, + "capacity_total": -1, + "capacity_used": 0, + "backup_enabled": "0", + "sort_order": 10, + "status": "active", + "description": "工作空间容量,按 GB月 计费" + } + ], + "storres_pricing_map": [ + { + "spec_code": "WS-STORAGE-STD", + "meter_mode": "ondemand", + "meter_unit": "GB月", + "settle_period": "month", + "is_default": "1", + "ppid": "" + }, + { + "spec_code": "WS-STORAGE-STD", + "meter_mode": "package", + "meter_unit": "GB月", + "settle_period": "month", + "is_default": "0", + "ppid": "" + } + ], + "_note": "ppid 待建定价项目后回填;meter_mode 是本模块的 ppid 挂载维度(与账号按 charge_mode 不同)" +} \ No newline at end of file diff --git a/json/storres_pricing_map.json b/json/storres_pricing_map.json new file mode 100644 index 0000000..60aacfd --- /dev/null +++ b/json/storres_pricing_map.json @@ -0,0 +1,70 @@ +{ + "tblname": "storres_pricing_map", + "params": { + "title": "存储定价映射", + "browserfields": [ + { + "field": "spec_id", + "title": "规格ID" + }, + { + "field": "meter_mode", + "title": "计量方式" + }, + { + "field": "meter_unit", + "title": "计量单位" + }, + { + "field": "settle_period", + "title": "结算周期" + }, + { + "field": "ppid", + "title": "定价项目ID" + }, + { + "field": "is_default", + "title": "缺省方式" + }, + { + "field": "status", + "title": "状态" + } + ], + "editfields": [ + { + "field": "spec_id", + "uitype": "Text" + }, + { + "field": "meter_mode", + "uitype": "Text" + }, + { + "field": "meter_unit", + "uitype": "Text" + }, + { + "field": "settle_period", + "uitype": "Text" + }, + { + "field": "ppid", + "uitype": "Text" + }, + { + "field": "is_default", + "uitype": "Text" + }, + { + "field": "status", + "uitype": "Text" + } + ], + "searchfields": [ + "spec_id", + "meter_mode" + ] + } +} diff --git a/json/storres_sample.json b/json/storres_sample.json new file mode 100644 index 0000000..1373d4d --- /dev/null +++ b/json/storres_sample.json @@ -0,0 +1,98 @@ +{ + "tblname": "storres_sample", + "params": { + "title": "容量采样", + "browserfields": [ + { + "field": "spec_id", + "title": "规格ID" + }, + { + "field": "target_type", + "title": "计量对象类型" + }, + { + "field": "target_id", + "title": "计量对象ID" + }, + { + "field": "sample_date", + "title": "采样日期" + }, + { + "field": "used_gb", + "title": "实测容量GB" + }, + { + "field": "quota_gb", + "title": "配额容量GB" + }, + { + "field": "over_gb", + "title": "超额GB" + }, + { + "field": "source", + "title": "采样来源" + } + ], + "editfields": [ + { + "field": "spec_id", + "uitype": "Text" + }, + { + "field": "subscription_id", + "uitype": "Text" + }, + { + "field": "target_type", + "uitype": "Text" + }, + { + "field": "target_id", + "uitype": "Text" + }, + { + "field": "userid", + "uitype": "Text" + }, + { + "field": "userorgid", + "uitype": "Text" + }, + { + "field": "sample_date", + "uitype": "Text" + }, + { + "field": "sample_time", + "uitype": "Text" + }, + { + "field": "used_gb", + "uitype": "Text" + }, + { + "field": "quota_gb", + "uitype": "Text" + }, + { + "field": "over_gb", + "uitype": "Text" + }, + { + "field": "source", + "uitype": "Text" + }, + { + "field": "status", + "uitype": "Text" + } + ], + "searchfields": [ + "spec_id", + "target_type" + ] + } +} diff --git a/json/storres_spec.json b/json/storres_spec.json new file mode 100644 index 0000000..a52d1bd --- /dev/null +++ b/json/storres_spec.json @@ -0,0 +1,94 @@ +{ + "tblname": "storres_spec", + "params": { + "title": "存储规格", + "browserfields": [ + { + "field": "spec_code", + "title": "规格编码" + }, + { + "field": "spec_name", + "title": "规格名称" + }, + { + "field": "storage_class", + "title": "存储类型" + }, + { + "field": "min_gb", + "title": "起售容量GB" + }, + { + "field": "capacity_total", + "title": "可售总量GB" + }, + { + "field": "capacity_used", + "title": "已分配GB" + }, + { + "field": "status", + "title": "状态" + } + ], + "editfields": [ + { + "field": "spec_code", + "uitype": "Text" + }, + { + "field": "spec_name", + "uitype": "Text" + }, + { + "field": "storage_class", + "uitype": "Text" + }, + { + "field": "description", + "uitype": "Text" + }, + { + "field": "min_gb", + "uitype": "Text" + }, + { + "field": "max_gb", + "uitype": "Text" + }, + { + "field": "step_gb", + "uitype": "Text" + }, + { + "field": "capacity_total", + "uitype": "Text" + }, + { + "field": "capacity_used", + "uitype": "Text" + }, + { + "field": "backup_enabled", + "uitype": "Text" + }, + { + "field": "attr_json", + "uitype": "Text" + }, + { + "field": "sort_order", + "uitype": "Text" + }, + { + "field": "status", + "uitype": "Text" + } + ], + "searchfields": [ + "spec_code", + "spec_name" + ] + } +} diff --git a/json/storres_usage.json b/json/storres_usage.json new file mode 100644 index 0000000..2b60bca --- /dev/null +++ b/json/storres_usage.json @@ -0,0 +1,142 @@ +{ + "tblname": "storres_usage", + "params": { + "title": "存储用量", + "browserfields": [ + { + "field": "spec_id", + "title": "规格ID" + }, + { + "field": "meter_mode", + "title": "计量方式" + }, + { + "field": "userorgid", + "title": "用户机构" + }, + { + "field": "period_start", + "title": "计费周期起" + }, + { + "field": "period_end", + "title": "计费周期止" + }, + { + "field": "billable_gb", + "title": "计费容量GB" + }, + { + "field": "amount", + "title": "交易金额" + }, + { + "field": "accounting_status", + "title": "记账状态" + } + ], + "editfields": [ + { + "field": "spec_id", + "uitype": "Text" + }, + { + "field": "meter_mode", + "uitype": "Text" + }, + { + "field": "subscription_id", + "uitype": "Text" + }, + { + "field": "userid", + "uitype": "Text" + }, + { + "field": "userorgid", + "uitype": "Text" + }, + { + "field": "use_date", + "uitype": "Text" + }, + { + "field": "use_time", + "uitype": "Text" + }, + { + "field": "period_start", + "uitype": "Text" + }, + { + "field": "period_end", + "uitype": "Text" + }, + { + "field": "sample_count", + "uitype": "Text" + }, + { + "field": "avg_gb", + "uitype": "Text" + }, + { + "field": "max_gb", + "uitype": "Text" + }, + { + "field": "billable_gb", + "uitype": "Text" + }, + { + "field": "usages", + "uitype": "Text" + }, + { + "field": "transno", + "uitype": "Text" + }, + { + "field": "amount", + "uitype": "Text" + }, + { + "field": "amount_currency", + "uitype": "Text" + }, + { + "field": "amount_base", + "uitype": "Text" + }, + { + "field": "cost", + "uitype": "Text" + }, + { + "field": "cost_currency", + "uitype": "Text" + }, + { + "field": "cost_base", + "uitype": "Text" + }, + { + "field": "discount_rate", + "uitype": "Text" + }, + { + "field": "accounting_status", + "uitype": "Text" + }, + { + "field": "status", + "uitype": "Text" + } + ], + "searchfields": [ + "spec_id", + "meter_mode" + ] + } +} diff --git a/models/storres_pricing_map.json b/models/storres_pricing_map.json new file mode 100644 index 0000000..6958ff0 --- /dev/null +++ b/models/storres_pricing_map.json @@ -0,0 +1,122 @@ +{ + "summary": [ + { + "name": "storres_pricing_map", + "title": "存储规格定价映射", + "primary": [ + "id" + ] + } + ], + "fields": [ + { + "name": "id", + "title": "ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "spec_id", + "title": "规格ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "meter_mode", + "title": "计量方式", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "meter_unit", + "title": "计量单位", + "type": "str", + "length": 16, + "nullable": "yes" + }, + { + "name": "settle_period", + "title": "结算周期", + "type": "str", + "length": 16, + "nullable": "yes", + "default": "month" + }, + { + "name": "ppid", + "title": "定价项目ID", + "type": "str", + "length": 32, + "nullable": "yes" + }, + { + "name": "is_default", + "title": "缺省方式", + "type": "str", + "length": 1, + "nullable": "yes", + "default": "0" + }, + { + "name": "status", + "title": "状态", + "type": "str", + "length": 16, + "nullable": "yes", + "default": "active" + }, + { + "name": "org_id", + "title": "所属机构", + "type": "str", + "length": 32, + "nullable": "yes" + }, + { + "name": "created_by", + "title": "创建人", + "type": "str", + "length": 32, + "nullable": "yes" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "yes" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "nullable": "yes" + } + ], + "indexes": [ + { + "name": "idx_spm_spec", + "idxtype": "index", + "idxfields": [ + "spec_id" + ] + }, + { + "name": "idx_spm_mode", + "idxtype": "unique", + "idxfields": [ + "spec_id", + "meter_mode" + ] + }, + { + "name": "idx_spm_ppid", + "idxtype": "index", + "idxfields": [ + "ppid" + ] + } + ] +} \ No newline at end of file diff --git a/models/storres_sample.json b/models/storres_sample.json new file mode 100644 index 0000000..63979c0 --- /dev/null +++ b/models/storres_sample.json @@ -0,0 +1,168 @@ +{ + "summary": [ + { + "name": "storres_sample", + "title": "容量采样", + "primary": [ + "id" + ] + } + ], + "fields": [ + { + "name": "id", + "title": "ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "spec_id", + "title": "规格ID", + "type": "str", + "length": 32, + "nullable": "yes" + }, + { + "name": "subscription_id", + "title": "订购ID", + "type": "str", + "length": 32, + "nullable": "yes" + }, + { + "name": "target_type", + "title": "计量对象类型", + "type": "str", + "length": 32, + "nullable": "yes" + }, + { + "name": "target_id", + "title": "计量对象ID", + "type": "str", + "length": 64, + "nullable": "yes" + }, + { + "name": "userid", + "title": "用户ID", + "type": "str", + "length": 32, + "nullable": "yes" + }, + { + "name": "userorgid", + "title": "用户机构", + "type": "str", + "length": 32, + "nullable": "yes" + }, + { + "name": "sample_date", + "title": "采样日期", + "type": "date", + "nullable": "yes" + }, + { + "name": "sample_time", + "title": "采样时间", + "type": "timestamp", + "nullable": "yes" + }, + { + "name": "used_gb", + "title": "实测容量GB", + "type": "double", + "length": 18, + "dec": 4, + "nullable": "yes" + }, + { + "name": "quota_gb", + "title": "配额容量GB", + "type": "double", + "length": 18, + "dec": 4, + "nullable": "yes" + }, + { + "name": "over_gb", + "title": "超额GB", + "type": "double", + "length": 18, + "dec": 4, + "nullable": "yes", + "default": "0" + }, + { + "name": "source", + "title": "采样来源", + "type": "str", + "length": 32, + "nullable": "yes", + "default": "scan" + }, + { + "name": "status", + "title": "状态", + "type": "str", + "length": 16, + "nullable": "yes", + "default": "active" + }, + { + "name": "org_id", + "title": "所属机构", + "type": "str", + "length": 32, + "nullable": "yes" + }, + { + "name": "created_by", + "title": "创建人", + "type": "str", + "length": 32, + "nullable": "yes" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "yes" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "nullable": "yes" + } + ], + "indexes": [ + { + "name": "idx_ss_target", + "idxtype": "index", + "idxfields": [ + "target_type", + "target_id", + "sample_date" + ] + }, + { + "name": "idx_ss_org", + "idxtype": "index", + "idxfields": [ + "userorgid", + "sample_date" + ] + }, + { + "name": "idx_ss_sub", + "idxtype": "index", + "idxfields": [ + "subscription_id", + "sample_date" + ] + } + ] +} \ No newline at end of file diff --git a/models/storres_spec.json b/models/storres_spec.json new file mode 100644 index 0000000..0a6d6db --- /dev/null +++ b/models/storres_spec.json @@ -0,0 +1,164 @@ +{ + "summary": [ + { + "name": "storres_spec", + "title": "存储规格", + "primary": [ + "id" + ] + } + ], + "fields": [ + { + "name": "id", + "title": "ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "spec_code", + "title": "规格编码", + "type": "str", + "length": 64, + "nullable": "no" + }, + { + "name": "spec_name", + "title": "规格名称", + "type": "str", + "length": 255, + "nullable": "yes" + }, + { + "name": "storage_class", + "title": "存储类型", + "type": "str", + "length": 32, + "nullable": "yes", + "default": "standard" + }, + { + "name": "description", + "title": "说明", + "type": "text", + "nullable": "yes" + }, + { + "name": "min_gb", + "title": "起售容量GB", + "type": "double", + "length": 18, + "dec": 2, + "nullable": "yes", + "default": "1" + }, + { + "name": "max_gb", + "title": "单次上限GB", + "type": "double", + "length": 18, + "dec": 2, + "nullable": "yes" + }, + { + "name": "step_gb", + "title": "购买步长GB", + "type": "double", + "length": 18, + "dec": 2, + "nullable": "yes", + "default": "1" + }, + { + "name": "capacity_total", + "title": "可售总量GB", + "type": "double", + "length": 18, + "dec": 2, + "nullable": "yes", + "default": "-1" + }, + { + "name": "capacity_used", + "title": "已分配GB", + "type": "double", + "length": 18, + "dec": 2, + "nullable": "yes", + "default": "0" + }, + { + "name": "backup_enabled", + "title": "含备份", + "type": "str", + "length": 1, + "nullable": "yes", + "default": "0" + }, + { + "name": "attr_json", + "title": "其他属性JSON", + "type": "text", + "nullable": "yes" + }, + { + "name": "sort_order", + "title": "排序", + "type": "int", + "nullable": "yes", + "default": "0" + }, + { + "name": "status", + "title": "状态", + "type": "str", + "length": 16, + "nullable": "yes", + "default": "active" + }, + { + "name": "org_id", + "title": "所属机构", + "type": "str", + "length": 32, + "nullable": "yes" + }, + { + "name": "created_by", + "title": "创建人", + "type": "str", + "length": 32, + "nullable": "yes" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "yes" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "nullable": "yes" + } + ], + "indexes": [ + { + "name": "idx_sspec_code", + "idxtype": "unique", + "idxfields": [ + "spec_code" + ] + }, + { + "name": "idx_sspec_class", + "idxtype": "index", + "idxfields": [ + "storage_class", + "status" + ] + } + ] +} \ No newline at end of file diff --git a/models/storres_usage.json b/models/storres_usage.json new file mode 100644 index 0000000..6b84e7d --- /dev/null +++ b/models/storres_usage.json @@ -0,0 +1,243 @@ +{ + "summary": [ + { + "name": "storres_usage", + "title": "存储资源用量", + "primary": [ + "id" + ] + } + ], + "fields": [ + { + "name": "id", + "title": "ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "spec_id", + "title": "规格ID", + "type": "str", + "length": 32, + "nullable": "yes" + }, + { + "name": "meter_mode", + "title": "计量方式", + "type": "str", + "length": 32, + "nullable": "yes" + }, + { + "name": "subscription_id", + "title": "订购ID", + "type": "str", + "length": 32, + "nullable": "yes" + }, + { + "name": "userid", + "title": "用户ID", + "type": "str", + "length": 32, + "nullable": "yes" + }, + { + "name": "userorgid", + "title": "用户机构", + "type": "str", + "length": 32, + "nullable": "yes" + }, + { + "name": "use_date", + "title": "出账日期", + "type": "date", + "nullable": "yes" + }, + { + "name": "use_time", + "title": "出账时间", + "type": "timestamp", + "nullable": "yes" + }, + { + "name": "period_start", + "title": "计费周期起", + "type": "date", + "nullable": "yes" + }, + { + "name": "period_end", + "title": "计费周期止", + "type": "date", + "nullable": "yes" + }, + { + "name": "sample_count", + "title": "采样点数", + "type": "int", + "nullable": "yes" + }, + { + "name": "avg_gb", + "title": "平均容量GB", + "type": "double", + "length": 18, + "dec": 4, + "nullable": "yes" + }, + { + "name": "max_gb", + "title": "峰值容量GB", + "type": "double", + "length": 18, + "dec": 4, + "nullable": "yes" + }, + { + "name": "billable_gb", + "title": "计费容量GB", + "type": "double", + "length": 18, + "dec": 4, + "nullable": "yes" + }, + { + "name": "usages", + "title": "用量信息JSON", + "type": "text", + "nullable": "yes" + }, + { + "name": "transno", + "title": "交易号", + "type": "str", + "length": 64, + "nullable": "yes" + }, + { + "name": "amount", + "title": "交易金额", + "type": "double", + "length": 18, + "dec": 4, + "nullable": "yes" + }, + { + "name": "amount_currency", + "title": "计费币种", + "type": "str", + "length": 8, + "nullable": "yes" + }, + { + "name": "amount_base", + "title": "折本位币金额", + "type": "double", + "length": 18, + "dec": 4, + "nullable": "yes" + }, + { + "name": "cost", + "title": "成本", + "type": "double", + "length": 18, + "dec": 4, + "nullable": "yes" + }, + { + "name": "cost_currency", + "title": "成本币种", + "type": "str", + "length": 8, + "nullable": "yes" + }, + { + "name": "cost_base", + "title": "折本位币成本", + "type": "double", + "length": 18, + "dec": 4, + "nullable": "yes" + }, + { + "name": "discount_rate", + "title": "折扣率", + "type": "double", + "length": 8, + "dec": 4, + "nullable": "yes" + }, + { + "name": "accounting_status", + "title": "记账状态", + "type": "str", + "length": 16, + "nullable": "yes", + "default": "pending" + }, + { + "name": "status", + "title": "状态", + "type": "str", + "length": 16, + "nullable": "yes", + "default": "active" + }, + { + "name": "org_id", + "title": "所属机构", + "type": "str", + "length": 32, + "nullable": "yes" + }, + { + "name": "created_by", + "title": "创建人", + "type": "str", + "length": 32, + "nullable": "yes" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "yes" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "nullable": "yes" + } + ], + "indexes": [ + { + "name": "idx_su_org", + "idxtype": "index", + "idxfields": [ + "userorgid", + "use_date" + ] + }, + { + "name": "idx_su_acct", + "idxtype": "index", + "idxfields": [ + "accounting_status" + ] + }, + { + "name": "idx_su_sub", + "idxtype": "index", + "idxfields": [ + "subscription_id", + "period_start" + ] + } + ] +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..90af60f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "storage-resource" +version = "1.0.0" +description = "存储资源模块 - 工作空间容量规格/采样/定价映射(spec×meter_mode→ppid)" +requires-python = ">=3.8" +dependencies = [ + "sqlor", + "bricks_for_python", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["storage_resource*"] diff --git a/scripts/load_path.py b/scripts/load_path.py new file mode 100644 index 0000000..2ff4d7f --- /dev/null +++ b/scripts/load_path.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""RBAC path registration for storage_resource module. + +宿主根目录执行:py3/bin/python pkgs/storage_resource/scripts/load_path.py +(pipeline-app 的 load_path.sh 自动扫描 pkgs/*/scripts/load_path.py) +""" +import subprocess + +MOD = "storage_resource" + +PATHS_ANY = [] + +PATHS_LOGINED = [ + f"/{MOD}", + f"/{MOD}/storres_spec/index.ui", + f"/{MOD}/storres_pricing_map/index.ui", + f"/{MOD}/storres_sample/index.ui", + f"/{MOD}/storres_usage/index.ui", +] + + +def register_paths(): + for path in PATHS_ANY: + subprocess.run(["py3/bin/python", "set_role_perm.py", "any", path]) + print(f" any: {path}") + for path in PATHS_LOGINED: + subprocess.run(["py3/bin/python", "set_role_perm.py", "logined", path]) + print(f" logined: {path}") + + +if __name__ == "__main__": + print(f"=== {MOD} RBAC registration ===") + register_paths() + print(f"Done. any={len(PATHS_ANY)} logined={len(PATHS_LOGINED)}") diff --git a/storage_resource/__init__.py b/storage_resource/__init__.py new file mode 100644 index 0000000..9b786f6 --- /dev/null +++ b/storage_resource/__init__.py @@ -0,0 +1,11 @@ +"""存储资源模块 - 工作空间容量规格/采样/定价映射(spec×meter_mode→ppid)""" +from .init import ( + load_storage_resource, + get_storage_spec, + get_storage_ppid, + record_storage_sample, + storage_charging, + resolve_ppid, +) + +__version__ = '1.0.0' diff --git a/storage_resource/init.py b/storage_resource/init.py new file mode 100644 index 0000000..92eaee1 --- /dev/null +++ b/storage_resource/init.py @@ -0,0 +1,366 @@ +"""storage_resource — 存储资源模块(工作空间容量 G/月) + +参照 llmage 范式,但 **ppid 挂法与账号资源不同**: + account_resource: acctres_spec × charge_mode(月/年/试用) → ppid + storage_resource: storres_spec × meter_mode(按量/包月/峰值) → ppid + +存储比账号多一层「采样」:容量是连续占用量,必须周期采样再按 GB月 汇总, +而账号是一次性/周期性开通事件。这正是「每类资源和 ppid 对应方式可不同, +但最终作用一样」——两者都通过 resolve_ppid(resource_ref_id, ctx) 对外统一。 + +宿主集成: + from storage_resource.init import load_storage_resource + load_storage_resource() +""" +import json +import logging +import datetime + +from ahserver.serverenv import ServerEnv +from appPublic.uniqueID import getID +from sqlor.dbpools import DBPools + +MODULE_NAME = 'storage_resource' +MODULE_VERSION = '1.0.0' +RESOURCE_TYPE = 'workspace_storage' # product_resource.resource_type 用这个值 + +logger = logging.getLogger(__name__) + + +def _get_dbname(): + env = ServerEnv() + return env.get_module_dbname(MODULE_NAME) + + +def _now(): + return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + +def _today(): + return datetime.date.today().strftime('%Y-%m-%d') + + +def _val(rec, key, default=''): + if isinstance(rec, dict): + return rec.get(key, default) + return getattr(rec, key, default) + + +# ═══════════════════════════════════════════════════════════════════ +# 规格(资源主表) +# ═══════════════════════════════════════════════════════════════════ + +async def storres_spec_list(ns=None): + ns = ns or {} + sql = "SELECT * FROM storres_spec WHERE 1=1" + params = {} + if ns.get('status'): + sql += " AND status=${status}$" + params['status'] = ns['status'] + if ns.get('storage_class'): + sql += " AND storage_class=${storage_class}$" + params['storage_class'] = ns['storage_class'] + sql += " ORDER BY sort_order, spec_code" + async with DBPools().sqlorContext(_get_dbname()) as sor: + return await sor.sqlExe(sql, params) + + +async def get_storage_spec(spec_id_or_code): + async with DBPools().sqlorContext(_get_dbname()) as sor: + recs = await sor.sqlExe( + "SELECT * FROM storres_spec WHERE id=${k}$ OR spec_code=${k}$", + {"k": spec_id_or_code}) + return recs[0] if recs else None + + +async def storres_spec_create(data): + data = dict(data or {}) + data.setdefault('id', getID()) + data.setdefault('created_at', _now()) + async with DBPools().sqlorContext(_get_dbname()) as sor: + await sor.C('storres_spec', data) + return data['id'] + + +async def storres_spec_update(data): + data = dict(data or {}) + data['updated_at'] = _now() + async with DBPools().sqlorContext(_get_dbname()) as sor: + await sor.U('storres_spec', data) + return True + + +async def storres_spec_delete(data): + async with DBPools().sqlorContext(_get_dbname()) as sor: + await sor.D('storres_spec', {'id': (data or {}).get('id', '')}) + return True + + +async def check_capacity_available(spec_id_or_code, need_gb): + """库存校验:capacity_total=-1 表示不限。""" + spec = await get_storage_spec(spec_id_or_code) + if not spec: + return False, '规格不存在' + total = float(_val(spec, 'capacity_total', -1) or -1) + if total < 0: + return True, 'unlimited' + used = float(_val(spec, 'capacity_used', 0) or 0) + need = float(need_gb or 0) + if used + need > total: + return False, '可售容量不足(剩余 %.2fGB)' % (total - used) + return True, 'ok' + + +# ═══════════════════════════════════════════════════════════════════ +# 定价映射(spec × meter_mode → ppid) +# ═══════════════════════════════════════════════════════════════════ + +async def storres_pricing_map_list(ns=None): + ns = ns or {} + sql = ("SELECT m.*, s.spec_code, s.spec_name FROM storres_pricing_map m " + "LEFT JOIN storres_spec s ON m.spec_id=s.id WHERE 1=1") + params = {} + if ns.get('spec_id'): + sql += " AND m.spec_id=${spec_id}$" + params['spec_id'] = ns['spec_id'] + sql += " ORDER BY s.sort_order, m.meter_mode" + async with DBPools().sqlorContext(_get_dbname()) as sor: + return await sor.sqlExe(sql, params) + + +async def storres_pricing_map_create(data): + data = dict(data or {}) + data.setdefault('id', getID()) + data.setdefault('created_at', _now()) + async with DBPools().sqlorContext(_get_dbname()) as sor: + await sor.C('storres_pricing_map', data) + return data['id'] + + +async def storres_pricing_map_update(data): + data = dict(data or {}) + data['updated_at'] = _now() + async with DBPools().sqlorContext(_get_dbname()) as sor: + await sor.U('storres_pricing_map', data) + return True + + +async def storres_pricing_map_delete(data): + async with DBPools().sqlorContext(_get_dbname()) as sor: + await sor.D('storres_pricing_map', {'id': (data or {}).get('id', '')}) + return True + + +async def get_storage_ppid(spec_id_or_code, meter_mode=None): + """**统一解析器契约**:资源 + 计量方式 → ppid。""" + spec = await get_storage_spec(spec_id_or_code) + if not spec: + logger.warning('存储规格不存在: %s', spec_id_or_code) + return None + spec_id = _val(spec, 'id') + + sql = ("SELECT ppid, meter_mode, meter_unit, settle_period FROM storres_pricing_map " + "WHERE spec_id=${spec_id}$ AND status='active'") + params = {'spec_id': spec_id} + if meter_mode: + sql += " AND meter_mode=${meter_mode}$" + params['meter_mode'] = meter_mode + else: + sql += " AND is_default='1'" + async with DBPools().sqlorContext(_get_dbname()) as sor: + recs = await sor.sqlExe(sql, params) + if not recs: + logger.warning('无定价映射: spec=%s meter_mode=%s', spec_id, meter_mode) + return None + return _val(recs[0], 'ppid') or None + + +async def resolve_ppid(resource_ref_id, ctx=None): + """资源定价解析器(注册给产品层统一调用)。 + + ctx: {'meter_mode': 'ondemand'|'package'|'peak', ...} + """ + ctx = ctx or {} + return await get_storage_ppid(resource_ref_id, ctx.get('meter_mode')) + + +# ═══════════════════════════════════════════════════════════════════ +# 容量采样(存储特有) +# ═══════════════════════════════════════════════════════════════════ + +async def record_storage_sample(spec_id, target_type, target_id, used_gb, + userid='', userorgid='', subscription_id='', + quota_gb=None, source='scan'): + """记一次容量采样。定时任务按机构/工作空间扫盘后调用。""" + over = 0.0 + if quota_gb not in (None, ''): + over = max(0.0, float(used_gb or 0) - float(quota_gb)) + rec = { + 'id': getID(), + 'spec_id': spec_id, + 'subscription_id': subscription_id, + 'target_type': target_type, + 'target_id': str(target_id), + 'userid': userid, + 'userorgid': userorgid, + 'sample_date': _today(), + 'sample_time': _now(), + 'used_gb': float(used_gb or 0), + 'quota_gb': float(quota_gb) if quota_gb not in (None, '') else None, + 'over_gb': over, + 'source': source, + 'status': 'active', + 'org_id': userorgid, + 'created_at': _now(), + } + async with DBPools().sqlorContext(_get_dbname()) as sor: + await sor.C('storres_sample', rec) + return rec['id'] + + +async def summarize_samples(subscription_id, period_start, period_end): + """汇总某订购在周期内的采样:返回 (采样点数, 均值GB, 峰值GB)。 + + GB月 计费用均值(更公平),峰值供 meter_mode='peak' 用。 + """ + async with DBPools().sqlorContext(_get_dbname()) as sor: + recs = await sor.sqlExe( + "SELECT COUNT(*) AS cnt, AVG(used_gb) AS avg_gb, MAX(used_gb) AS max_gb " + "FROM storres_sample WHERE subscription_id=${sid}$ " + "AND sample_date >= ${ps}$ AND sample_date <= ${pe}$", + {'sid': subscription_id, 'ps': period_start, 'pe': period_end}) + if not recs: + return 0, 0.0, 0.0 + r = recs[0] + cnt = int(_val(r, 'cnt', 0) or 0) + # SUM/AVG 返回 Decimal,必须 float() 否则 json 序列化炸 + avg_gb = float(_val(r, 'avg_gb', 0) or 0) + max_gb = float(_val(r, 'max_gb', 0) or 0) + return cnt, round(avg_gb, 4), round(max_gb, 4) + + +# ═══════════════════════════════════════════════════════════════════ +# 出账 +# ═══════════════════════════════════════════════════════════════════ + +async def storage_charging(spec_id_or_code, meter_mode, userid, userorgid, + subscription_id, period_start, period_end, + discount_rate=None): + """按周期出账:汇总采样 → 解析 ppid → pricing 计费 → 落用量记录。""" + env = ServerEnv() + cnt, avg_gb, max_gb = await summarize_samples(subscription_id, period_start, period_end) + billable_gb = max_gb if meter_mode == 'peak' else avg_gb + + ppid = await get_storage_ppid(spec_id_or_code, meter_mode) + usages = { + 'storage_gb': billable_gb, + 'avg_gb': avg_gb, + 'max_gb': max_gb, + 'sample_count': cnt, + 'meter_mode': meter_mode, + } + + amount = 0.0 + if ppid: + charging = getattr(env, 'pricing_program_charging', None) + if charging is None: + logger.warning('pricing 模块未加载,存储计费金额记 0(ppid=%s)', ppid) + else: + try: + r = await charging(ppid, usages) + if isinstance(r, dict): + amount = float(r.get('amount', 0) or 0) + elif isinstance(r, (int, float)): + amount = float(r) + except Exception as e: + logger.error('存储计费失败 ppid=%s: %s', ppid, e) + else: + logger.warning('无 ppid,存储计费记 0: spec=%s mode=%s', spec_id_or_code, meter_mode) + + rate = float(discount_rate) if discount_rate not in (None, '') else 1.0 + final_amount = round(amount * rate, 4) + + spec = await get_storage_spec(spec_id_or_code) + rec = { + 'id': getID(), + 'spec_id': _val(spec, 'id') if spec else '', + 'meter_mode': meter_mode, + 'subscription_id': subscription_id, + 'userid': userid, + 'userorgid': userorgid, + 'use_date': _today(), + 'use_time': _now(), + 'period_start': period_start, + 'period_end': period_end, + 'sample_count': cnt, + 'avg_gb': avg_gb, + 'max_gb': max_gb, + 'billable_gb': billable_gb, + 'usages': json.dumps(usages, ensure_ascii=False), + 'transno': getID(), + 'amount': final_amount, + 'discount_rate': rate, + 'accounting_status': 'pending', + 'status': 'active', + 'org_id': userorgid, + 'created_at': _now(), + } + async with DBPools().sqlorContext(_get_dbname()) as sor: + await sor.C('storres_usage', rec) + logger.info('存储出账: spec=%s mode=%s 计费=%sGB(采样%d点) 原价=%s 折扣=%s 应收=%s', + spec_id_or_code, meter_mode, billable_gb, cnt, amount, rate, final_amount) + return rec + + +async def get_accounting_storres_usages(limit=200): + async with DBPools().sqlorContext(_get_dbname()) as sor: + return await sor.sqlExe( + "SELECT * FROM storres_usage WHERE accounting_status='pending' " + "ORDER BY use_time LIMIT " + str(int(limit)), {}) + + +async def mark_storres_accounted(usage_id, status='accounted'): + async with DBPools().sqlorContext(_get_dbname()) as sor: + await sor.U('storres_usage', {'id': usage_id, 'accounting_status': status, + 'updated_at': _now()}) + return True + + +# ═══════════════════════════════════════════════════════════════════ +# Module Loader +# ═══════════════════════════════════════════════════════════════════ + +def load_storage_resource(): + env = ServerEnv() + + env.storres_spec_list = storres_spec_list + env.storres_spec_create = storres_spec_create + env.storres_spec_update = storres_spec_update + env.storres_spec_delete = storres_spec_delete + env.get_storage_spec = get_storage_spec + env.check_capacity_available = check_capacity_available + + env.storres_pricing_map_list = storres_pricing_map_list + env.storres_pricing_map_create = storres_pricing_map_create + env.storres_pricing_map_update = storres_pricing_map_update + env.storres_pricing_map_delete = storres_pricing_map_delete + env.get_storage_ppid = get_storage_ppid + + env.record_storage_sample = record_storage_sample + env.summarize_samples = summarize_samples + env.storage_charging = storage_charging + env.get_accounting_storres_usages = get_accounting_storres_usages + env.mark_storres_accounted = mark_storres_accounted + + reg = getattr(env, 'register_resource_pricing_resolver', None) + if reg: + reg(RESOURCE_TYPE, resolve_ppid) + else: + existing = getattr(env, '_resource_pricing_resolvers', None) or {} + existing[RESOURCE_TYPE] = resolve_ppid + env._resource_pricing_resolvers = existing + logger.info('产品层解析器注册表未就绪,已暂存 resolver: %s', RESOURCE_TYPE) + + logger.info('storage_resource module loaded (v%s, resource_type=%s)', + MODULE_VERSION, RESOURCE_TYPE) + return True