diff --git a/README.md b/README.md
index cfdaea86..86d0c8a8 100644
--- a/README.md
+++ b/README.md
@@ -109,6 +109,7 @@ SKIP_CHECKS=1 git push
- [接口规划](docs/api.md)
- [业务规则](docs/business-rules.md)
- [分销网管理后台设计方案](docs/分销网管理后台设计方案.md)
+- [双运营主体多租户改造分析](docs/双运营主体多租户改造分析.md)
### 分销独立测试库
diff --git a/backend/internal/modules/paymentconfig/dto.go b/backend/internal/modules/paymentconfig/dto.go
index ea86d723..d53f49e9 100644
--- a/backend/internal/modules/paymentconfig/dto.go
+++ b/backend/internal/modules/paymentconfig/dto.go
@@ -46,10 +46,7 @@ type ConfigDTO struct {
Status string `json:"status"`
Environment string `json:"environment"`
BusinessTags []string `json:"business_tags"`
- ReferenceCount int64 `json:"reference_count"`
- TotalTransactions int64 `json:"total_transactions"`
- TotalAmountCent int64 `json:"total_amount_cent"`
- LastUsedAt *string `json:"last_used_at"`
+ HasPaymentRecords bool `json:"has_payment_records"`
CreatedBy *uint64 `json:"created_by"`
UpdatedBy *uint64 `json:"updated_by"`
CreatedAt string `json:"created_at"`
diff --git a/backend/internal/modules/paymentconfig/mutation.go b/backend/internal/modules/paymentconfig/mutation.go
index 464fcecf..3b1138f5 100644
--- a/backend/internal/modules/paymentconfig/mutation.go
+++ b/backend/internal/modules/paymentconfig/mutation.go
@@ -295,17 +295,17 @@ func (r *Repository) Delete(ctx context.Context, id uint64, actorID uint64, meta
return err
}
- var usedCount int64
usedDB := tx.Model(&model.PaymentOrder{}).
Where("payment_config_id = ?", item.ID).
Or("(payment_config_id = ? OR payment_config_id IS NULL) AND provider = ? AND merchant_id = ? AND pay_way = ?", 0, item.Provider, item.MerchantID, item.PayWay)
- if err := usedDB.
- Count(&usedCount).Error; err != nil {
- return err
- }
- if usedCount > 0 {
+ var usedPayment model.PaymentOrder
+ err := usedDB.Select("id").Limit(1).Take(&usedPayment).Error
+ if err == nil {
return ErrCannotDeleteInUse
}
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
if err := tx.Delete(&item).Error; err != nil {
return err
diff --git a/backend/internal/modules/paymentconfig/presenter.go b/backend/internal/modules/paymentconfig/presenter.go
index 386f07e1..5716a852 100644
--- a/backend/internal/modules/paymentconfig/presenter.go
+++ b/backend/internal/modules/paymentconfig/presenter.go
@@ -9,32 +9,25 @@ import (
// toDTO 转换为 DTO
func (r *Repository) toDTO(item model.PaymentMerchantConfig, includeSecret bool) (ConfigDTO, error) {
dto := ConfigDTO{
- ID: item.ID,
- Name: item.Name,
- Provider: item.Provider,
- MerchantID: item.MerchantID,
- GatewayURL: item.GatewayURL,
- NotifyURL: item.NotifyURL,
- JumpURL: item.JumpURL,
- PayWay: item.PayWay,
- JSPayFlag: item.JSPayFlag,
- SignType: item.SignType,
- ExtraConfig: item.ExtraConfig,
- IsDefault: item.IsDefault,
- Status: item.Status,
- Environment: item.Environment,
- BusinessTags: item.BusinessTags,
- TotalTransactions: item.TotalTransactions,
- TotalAmountCent: item.TotalAmountCent,
- CreatedBy: item.CreatedBy,
- UpdatedBy: item.UpdatedBy,
- CreatedAt: item.CreatedAt.Format(time.RFC3339),
- UpdatedAt: item.UpdatedAt.Format(time.RFC3339),
- }
-
- if item.LastUsedAt != nil {
- s := item.LastUsedAt.Format(time.RFC3339)
- dto.LastUsedAt = &s
+ ID: item.ID,
+ Name: item.Name,
+ Provider: item.Provider,
+ MerchantID: item.MerchantID,
+ GatewayURL: item.GatewayURL,
+ NotifyURL: item.NotifyURL,
+ JumpURL: item.JumpURL,
+ PayWay: item.PayWay,
+ JSPayFlag: item.JSPayFlag,
+ SignType: item.SignType,
+ ExtraConfig: item.ExtraConfig,
+ IsDefault: item.IsDefault,
+ Status: item.Status,
+ Environment: item.Environment,
+ BusinessTags: item.BusinessTags,
+ CreatedBy: item.CreatedBy,
+ UpdatedBy: item.UpdatedBy,
+ CreatedAt: item.CreatedAt.Format(time.RFC3339),
+ UpdatedAt: item.UpdatedAt.Format(time.RFC3339),
}
// 只有明确要求时才解密并返回密钥
diff --git a/backend/internal/modules/paymentconfig/query.go b/backend/internal/modules/paymentconfig/query.go
index a42a334e..ff683d7c 100644
--- a/backend/internal/modules/paymentconfig/query.go
+++ b/backend/internal/modules/paymentconfig/query.go
@@ -2,9 +2,7 @@ package paymentconfig
import (
"context"
- "database/sql/driver"
"errors"
- "time"
"hfb_sys/backend/internal/model"
@@ -63,10 +61,8 @@ func (r *Repository) List(ctx context.Context, query ListQuery) ([]ConfigDTO, in
}
dtos = append(dtos, dto)
}
- if err := r.applySuccessfulPaymentStats(ctx, dtos); err != nil {
- return nil, 0, err
- }
- if err := r.applyReferenceCounts(ctx, dtos); err != nil {
+
+ if err := r.applyPaymentReferenceFlags(ctx, dtos); err != nil {
return nil, 0, err
}
@@ -86,14 +82,12 @@ func (r *Repository) FindByID(ctx context.Context, id uint64, includeSecret bool
if err != nil {
return nil, err
}
- statDTOs := []ConfigDTO{dto}
- if err := r.applySuccessfulPaymentStats(ctx, statDTOs); err != nil {
+
+ dtos := []ConfigDTO{dto}
+ if err := r.applyPaymentReferenceFlags(ctx, dtos); err != nil {
return nil, err
}
- if err := r.applyReferenceCounts(ctx, statDTOs); err != nil {
- return nil, err
- }
- dto = statDTOs[0]
+ dto = dtos[0]
if includeSecret {
if err := appendAuditLog(r.db.WithContext(ctx), actorID, "payment_config.view_secret", item.ID, meta, map[string]any{
"name": item.Name,
@@ -106,15 +100,9 @@ func (r *Repository) FindByID(ctx context.Context, id uint64, includeSecret bool
return &dto, nil
}
-type paymentConfigSuccessStat struct {
- ConfigID uint64
- TotalTransactions int64
- TotalAmountCent int64
- LastPaidAt nullableTime
-}
-
-// applySuccessfulPaymentStats 使用真实成功支付流水覆盖配置统计,避免仅打开二维码也被算作成交。
-func (r *Repository) applySuccessfulPaymentStats(ctx context.Context, dtos []ConfigDTO) error {
+// applyPaymentReferenceFlags 批量标记配置是否已有支付单,用于前端提前禁用删除。
+// 真正的删除保护仍由 Delete 按包含历史数据的口径校验。
+func (r *Repository) applyPaymentReferenceFlags(ctx context.Context, dtos []ConfigDTO) error {
if len(dtos) == 0 {
return nil
}
@@ -123,109 +111,26 @@ func (r *Repository) applySuccessfulPaymentStats(ctx context.Context, dtos []Con
for idx, dto := range dtos {
ids = append(ids, dto.ID)
indexByID[dto.ID] = idx
- dtos[idx].TotalTransactions = 0
- dtos[idx].TotalAmountCent = 0
- dtos[idx].LastUsedAt = nil
+ dtos[idx].HasPaymentRecords = false
}
- var rows []paymentConfigSuccessStat
+ var referencedIDs []uint64
if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).
- Select("payment_config_id AS config_id, COUNT(*) AS total_transactions, COALESCE(SUM(amount_cent), 0) AS total_amount_cent, MAX(paid_at) AS last_paid_at").
- Where("payment_config_id IN ? AND biz_type = ? AND status = ?", ids, "order_pay", "paid").
- Group("payment_config_id").
- Scan(&rows).Error; err != nil {
+ Where("payment_config_id IN ?", ids).
+ Distinct().
+ Pluck("payment_config_id", &referencedIDs).Error; err != nil {
return err
}
- for _, row := range rows {
- idx, ok := indexByID[row.ConfigID]
+ for _, id := range referencedIDs {
+ idx, ok := indexByID[id]
if !ok {
continue
}
- dtos[idx].TotalTransactions = row.TotalTransactions
- dtos[idx].TotalAmountCent = row.TotalAmountCent
- if row.LastPaidAt.Time != nil {
- value := row.LastPaidAt.Time.Format(time.RFC3339)
- dtos[idx].LastUsedAt = &value
- }
+ dtos[idx].HasPaymentRecords = true
}
return nil
}
-// applyReferenceCounts 使用删除保护的同一口径统计配置被支付记录引用的次数。
-func (r *Repository) applyReferenceCounts(ctx context.Context, dtos []ConfigDTO) error {
- for idx := range dtos {
- count, err := r.referenceCount(ctx, dtos[idx])
- if err != nil {
- return err
- }
- dtos[idx].ReferenceCount = count
- }
- return nil
-}
-
-func (r *Repository) referenceCount(ctx context.Context, dto ConfigDTO) (int64, error) {
- var count int64
- db := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).
- Where("payment_config_id = ?", dto.ID).
- Or("(payment_config_id = ? OR payment_config_id IS NULL) AND provider = ? AND merchant_id = ? AND pay_way = ?", 0, dto.Provider, dto.MerchantID, dto.PayWay)
- if err := db.Count(&count).Error; err != nil {
- return 0, err
- }
- return count, nil
-}
-
-type nullableTime struct {
- Time *time.Time
-}
-
-// Value 实现 driver.Valuer,让 GORM 能把 nullableTime 当作普通扫描字段处理。
-func (nt nullableTime) Value() (driver.Value, error) {
- if nt.Time == nil {
- return nil, nil
- }
- return *nt.Time, nil
-}
-
-// Scan 兼容 MySQL 聚合函数可能返回的 time.Time 与字符串时间。
-func (nt *nullableTime) Scan(value any) error {
- switch v := value.(type) {
- case nil:
- nt.Time = nil
- case time.Time:
- nt.Time = &v
- case []byte:
- return nt.scanString(string(v))
- case string:
- return nt.scanString(v)
- default:
- nt.Time = nil
- }
- return nil
-}
-
-func (nt *nullableTime) scanString(value string) error {
- if value == "" {
- nt.Time = nil
- return nil
- }
- for _, layout := range []string{
- time.RFC3339Nano,
- time.RFC3339,
- "2006-01-02 15:04:05.999999999-07:00",
- "2006-01-02 15:04:05-07:00",
- "2006-01-02 15:04:05.999999999",
- "2006-01-02 15:04:05",
- } {
- parsed, err := time.Parse(layout, value)
- if err == nil {
- nt.Time = &parsed
- return nil
- }
- }
- nt.Time = nil
- return nil
-}
-
// FindRuntimeByID 根据配置 ID 查询运行时配置,不写审计日志,供支付单回溯原配置使用。
func (r *Repository) FindRuntimeByID(ctx context.Context, id uint64, includeSecret bool) (*ConfigDTO, error) {
var item model.PaymentMerchantConfig
diff --git a/backend/internal/modules/paymentconfig/repository_test.go b/backend/internal/modules/paymentconfig/repository_test.go
index 3127f2ab..af25715c 100644
--- a/backend/internal/modules/paymentconfig/repository_test.go
+++ b/backend/internal/modules/paymentconfig/repository_test.go
@@ -67,8 +67,8 @@ func TestValidateCreateRequestRequiresShunchengSecretID(t *testing.T) {
}
}
-// TestListUsesSuccessfulPaymentStats 验证配置页只统计成功支付,不把打开二维码或取消订单算作成交。
-func TestListUsesSuccessfulPaymentStats(t *testing.T) {
+// TestListFlagsPaymentReferences 验证列表只批量标记是否存在支付引用,不再聚合所有支付记录。
+func TestListFlagsPaymentReferences(t *testing.T) {
db := database.NewTestDB()
if err := database.AutoMigrateIfMissing(db, &model.PaymentMerchantConfig{}, &model.PaymentOrder{}); err != nil {
t.Fatalf("migrate failed: %v", err)
@@ -142,13 +142,46 @@ func TestListUsesSuccessfulPaymentStats(t *testing.T) {
if total != 1 || len(items) != 1 {
t.Fatalf("items/total = %d/%d, want 1/1", len(items), total)
}
- if items[0].TotalTransactions != 1 || items[0].TotalAmountCent != 10130 {
- t.Fatalf("success stats = %d/%d, want 1/10130", items[0].TotalTransactions, items[0].TotalAmountCent)
+ if !items[0].HasPaymentRecords {
+ t.Fatal("has_payment_records should be true")
}
- if items[0].ReferenceCount != 3 {
- t.Fatalf("reference_count = %d, want 3", items[0].ReferenceCount)
+}
+
+func TestDeleteRejectsReferencedConfig(t *testing.T) {
+ db := database.NewTestDB()
+ if err := database.AutoMigrateIfMissing(db, &model.PaymentMerchantConfig{}, &model.PaymentOrder{}); err != nil {
+ t.Fatalf("migrate failed: %v", err)
}
- if items[0].LastUsedAt == nil {
- t.Fatal("last success pay time should not be nil")
+ config := model.PaymentMerchantConfig{
+ Name: "已有交易配置",
+ Provider: "lakala",
+ MerchantID: "M1",
+ PayWay: "WXZF",
+ Status: "active",
+ Environment: "production",
+ }
+ if err := db.Create(&config).Error; err != nil {
+ t.Fatalf("create config failed: %v", err)
+ }
+ payment := model.PaymentOrder{
+ PaymentNo: "PAY-DELETE-GUARD",
+ OrderID: 1,
+ OrderNo: "ORD-DELETE-GUARD",
+ UserID: 2,
+ PaymentConfigID: config.ID,
+ Provider: "lakala",
+ MerchantID: "M1",
+ ThirdOrderID: "PAY-DELETE-GUARD",
+ PayWay: "WXZF",
+ BizType: "order_pay",
+ Status: "paid",
+ }
+ if err := db.Create(&payment).Error; err != nil {
+ t.Fatalf("create payment failed: %v", err)
+ }
+
+ repo := NewRepository(db, nil)
+ if err := repo.Delete(t.Context(), config.ID, 0, AuditMeta{}); err != ErrCannotDeleteInUse {
+ t.Fatalf("Delete() error = %v, want ErrCannotDeleteInUse", err)
}
}
diff --git a/docs/双运营主体多租户改造分析.md b/docs/双运营主体多租户改造分析.md
new file mode 100644
index 00000000..a7602458
--- /dev/null
+++ b/docs/双运营主体多租户改造分析.md
@@ -0,0 +1,1910 @@
+# 双运营主体多租户改造分析
+
+> 状态:分析稿 v0.2(第一轮现状审计 + 第二轮业务与数据边界设计)
+> 更新时间:2026-11-14
+> 目标:在现有 HFB Sys 上增加“解忧”网站,使其与当前网站共享号库,但对外表现为两个独立公司,且用户、支付、钱包、客服、后台权限和统计相互独立。
+> 本文只分析现状和方案,不包含代码实施。
+
+> 阅读提示:第 1~18 节保留第一轮现状审计和总体方案;第 19 节以后是第二轮深度分析,重点回答跨公司订单、敏感信息交接、资金对账、全量数据归属、迁移顺序和首期范围。若两轮表述有冲突,以第 19 节以后更明确的“买方租户 / 货源租户 / 支付租户”模型为准。
+
+## 文档导航
+
+- [第一轮结论与现状审计](#1-结论摘要)
+- [第二轮深度分析结论](#19-第二轮深度分析结论)
+- [角色化订单与跨公司履约](#21-角色化订单模型)
+- [全量数据归属矩阵](#22-全量数据归属矩阵)
+- [资金、退款与公司间结算](#23-资金退款和公司间结算)
+- [认证与后台隔离](#24-认证后台和异步任务的隔离)
+- [迁移与上线顺序](#25-数据迁移与兼容策略)
+- [仍需确认的业务决策](#27-仍需最终确认的业务决策)
+- [最终建议](#29-最终建议)
+
+## 1. 结论摘要
+
+当前项目已经具备“同一平台下的分销子站”,但没有完成“两个独立运营主体”的租户隔离。
+
+现有多数实现仍然假设:
+
+- 当前主站是唯一主站,使用 `0` 或 `NULL` 表示。
+- 分销站是挂在主站下面的销售渠道。
+- 用户手机号全局唯一。
+- 登录和令牌没有绑定站点。
+- 支付找不到分站商户时可以回退主站默认商户。
+- 主站运营后台可以查看全平台用户、订单、支付和财务。
+- 统计主要通过商品来源、订单角色和分成参与方推导,而不是通过明确的“买方租户、货源租户、支付租户”。
+
+因此,“解忧”不能只按现有分销站新增一条站点记录并更换 Logo。推荐将其升级为:
+
+> 共享商品与库存的多租户平台,每个网站是独立租户;用户、认证、支付、钱包、客服、后台权限、统计和品牌配置按租户隔离。
+
+推荐架构:
+
+- 单套 Go 后端。
+- 单套 MySQL 主库。
+- 同一套前端代码,可按域名加载不同租户配置。
+- 每个租户使用独立域名和独立后台入口。
+- 共享 `game_accounts`、`rental_listings` 和库存锁定。
+- 所有租户私有数据必须带显式 `tenant_id`。
+- 旧站也必须成为显式租户,不再使用 `0=主站` 作为长期模型。
+- 跨租户订单使用“买方租户、货源租户、支付租户、用户所属租户”多字段建模。
+- 首期由买方网站作为对客合同卖方和收款方,货源网站作为内部供货方,两家公司通过应收应付对账。
+- 跨租户订单的客服、账号交接、敏感附件和通知必须按订单角色授权,不能只绑定一个站点字段。
+
+只有在两家公司要求数据库管理员、备份和物理存储层面也完全隔离时,才建议采用租户独立数据库。独立数据库会显著增加共享库存锁定、跨站订单、退款和对账的复杂度。
+
+## 2. 术语定义
+
+| 术语 | 含义 |
+|---|---|
+| 平台 | 提供共享号库、订单基础设施和平台级运维能力的内部系统 |
+| 租户 | 一个对外独立运营的网站和公司主体,例如当前旧站、解忧 |
+| 主站 | 当前旧站。改造后应成为普通租户,而不是特殊值 `0` |
+| 站点 | 产品界面中的租户载体。本文建议继续沿用现有 `distribution_sites` 表作为租户主体,减少无关表重命名 |
+| 货源租户 | 商品或游戏账号由哪个租户的卖家上传,以及由哪个租户负责来源归属 |
+| 买方租户 | 用户实际下单和收银的网站 |
+| 支付租户 | 实际创建支付单、使用支付商户并收取款项的租户 |
+| 租户私有数据 | 只能被所属租户用户和后台查看的数据 |
+| 平台内部数据 | 平台超级管理员用于运维、审计和跨租户对账的数据,不向公司后台开放 |
+
+本文中的“租户”和“站点”在大多数场景可视为同一个对象。落地时建议避免同时引入两套概念,否则权限、支付和财务归属会继续模糊。
+
+## 3. 当前项目总体架构
+
+### 3.1 技术架构
+
+当前是模块化单体:
+
+- 后端:Go、Gin、GORM、MySQL、Redis。
+- 用户前台与主运营后台:Vue 3、TypeScript、Element Plus。
+- 分销后台:React、TypeScript、Ant Design。
+- 公网入口:Caddy,托管静态资源并反代后端。
+- 文件:OSS/MinIO,通过后端鉴权或公开接口访问。
+- 定时任务:订单超时、分销结算释放、短信发送、文件清理等。
+
+主要模块:
+
+- 用户与认证
+- 实名与风控
+- 商品、游戏账号、审核和货架
+- 租赁订单、交接、归还、结账、争议
+- 支付、退款、支付商户配置
+- 钱包、提现、收款账户
+- 分销站点、分账、结算和财务快照
+- 客服、群聊、二维码、通知
+- 邀请返利、广告归因
+- 主站运营后台、财务后台、分销后台
+
+### 3.2 当前前端部署
+
+Caddy 将主站和所有分销域名都指向同一份前台静态资源:
+
+- 主站静态资源位于 `/srv/dist`。
+- 分销管理后台位于 `/srv/distribution-admin`。
+- 任意绑定的租户域名都使用同一个 `storefront` 配置。
+- 分销后台固定使用 `distribution.<主站域名>`。
+
+参考:
+
+- [Caddyfile](../deploy/caddy/Caddyfile)
+- [deploy/README.md](../deploy/README.md)
+
+这意味着解忧可以在不改部署拓扑的情况下上线独立域名,但“看起来独立”还必须依赖运行时租户配置和严格的后端隔离。
+
+## 4. 当前分销系统分析
+
+### 4.1 已具备的能力
+
+现有分销主体表已经存储了大部分站点级配置:
+
+- 站点编码
+- 站点名称
+- 域名
+- 状态
+- 是否自动铺货
+- Logo、favicon、轮播、公告和页脚
+- 首页文案
+- 默认客服组
+- 分站收号比例、收号成本差
+- 税费
+- 结算冷却期
+- 分站短信配置
+
+核心模型见:
+
+- [distribution.go](../backend/internal/model/distribution.go)
+- [分销网管理后台设计方案.md](分销网管理后台设计方案.md)
+- [分站收号定价分析与改造方案.md](分站收号定价分析与改造方案.md)
+
+### 4.2 共享号库与站点货架
+
+现有商品模型已经是“共享商品库 + 站点展示关系”:
+
+- `game_accounts` 保存游戏账号资产。
+- `rental_listings` 保存商品和卖家。
+- `distribution_site_accounts` 保存某商品在某租户货架上的展示状态和站点加价。
+
+商品被审核通过后,系统可为启用自动铺货的站点写入货架关系。站点可以:
+
+- 隐藏商品。
+- 重新同步商品。
+- 调整本站售价。
+- 按站点展示商品详情。
+
+库存锁定仍然在共享商品上完成,下单事务会锁定商品并检查 `in_transaction`,可以避免两个网站同时卖出同一个账号。
+
+这部分是最值得复用的领域。
+
+### 4.3 当前订单来源模式
+
+分销模块根据“买方站点”和“商品来源站点”推导订单模式:
+
+| 场景 | 模式 |
+|---|---|
+| 主站商品、主站购买 | 普通订单 |
+| 分站商品、主站购买 | `two_party_distributor_receive` |
+| 主站商品、分站购买 | `two_party_platform_receive` |
+| 同一分站商品、同一分站购买 | `site_self_sell` |
+| A 分站商品、B 分站购买 | `three_party` |
+
+实现见:
+
+- [distribution/lifecycle.go](../backend/internal/modules/distribution/lifecycle.go)
+- [distribution/pricing.go](../backend/internal/modules/distribution/pricing.go)
+
+订单会记录:
+
+- `distribution_site_id`
+- `distribution_mode`
+- `distribution_rule_version_id`
+- 商品货架加价快照
+- 号主价格、买家价格和平台利润快照
+
+支付成功后会生成分销财务快照和参与方快照:
+
+- `distribution_order_financials`
+- `distribution_order_parties`
+
+这套能力可以继续支撑租户间供货分成,但不能直接把一个 `distribution_site_id` 理解为“这个订单只属于这个租户”。
+
+### 4.4 当前支付与财务
+
+现状:
+
+- `payment_merchant_configs` 是全局表。
+- `distribution_site_payment_channels` 将商户配置绑定到站点。
+- 分站订单优先使用本站绑定商户。
+- 找不到本站商户时会回退主站默认商户。
+- `payment_orders` 没有独立的站点字段。
+- 分销站点有站点钱包,使用 `user_id=0 + site_id` 表示租户主体资金。
+- 站点钱包、提现和结算已有部分站点隔离。
+
+关键代码:
+
+- [payment/repository.go](../backend/internal/modules/payment/repository.go)
+- [payment/payment_start.go](../backend/internal/modules/payment/payment_start.go)
+- [payment.go](../backend/internal/model/payment.go)
+- [wallet.go](../backend/internal/model/wallet.go)
+- [distribution.go](../backend/internal/model/distribution.go)
+
+### 4.5 当前统计
+
+当前存在两套统计视角:
+
+1. 主站运营后台统计全平台。
+2. 分销后台按站点统计站点业务。
+
+主站运营后台直接统计全局用户、商品、订单、钱包流水和待办:
+
+- [admindashboard/repository.go](../backend/internal/modules/admindashboard/repository.go)
+
+分销后台通过订单主字段和分销参与方联合判断站点相关订单:
+
+```text
+distribution_site_id = 当前站点
+OR distribution_order_parties.site_id/party_id = 当前站点
+```
+
+参考:
+
+- [distribution/repository.go](../backend/internal/modules/distribution/repository.go)
+
+该口径适合“平台查看分销商全部贡献”,不适合直接作为两个公司的独立业务报表。
+
+## 5. 当前端到端流程
+
+### 5.1 当前分站注册
+
+```text
+用户访问分站域名
+ -> 前端识别站点编码
+ -> 请求携带 X-Distribution-Site-Code
+ -> 后端按站点编码查询 distribution_sites
+ -> 创建或查找 users.phone
+ -> 新用户写入 distribution_site_id
+ -> 签发 JWT
+```
+
+问题:
+
+- 用户表仍按手机号全局唯一。
+- 已存在用户不会更新站点归属。
+- 登录查询不区分站点。
+- 客户端请求头是站点编码的权威来源。
+- 无效站点编码可能被解析为 `0`,即旧站。
+
+### 5.2 当前浏览和下单
+
+```text
+分站域名
+ -> 列表查询检查 distribution_site_accounts
+ -> 前端选择商品
+ -> 下单接口携带站点编码
+ -> 检查商品是否在站点货架展示
+ -> 检查共享库存并锁定
+ -> 根据买方站点和货源站点计算订单模式
+ -> 保存订单和价格快照
+```
+
+问题:
+
+- 列表检查了货架,但详情和图片没有完全检查货架。
+- 订单只保存一个主站点字段,并不能直接表达购买租户、货源租户和支付租户。
+- 用户身份未绑定当前租户。
+
+### 5.3 当前支付和退款
+
+```text
+租客发起支付
+ -> 根据订单 distribution_site_id 查站点支付通道
+ -> 找不到则回退全局默认商户
+ -> 创建支付单
+ -> 渠道回调
+ -> 更新支付和订单状态
+ -> 写入分销财务快照
+ -> 后续退款使用支付单当时配置
+```
+
+问题:
+
+- 支付单没有显式租户。
+- 默认回退可能造成跨公司收款。
+- 主站运营后台可以看到全部支付,不能直接给公司使用。
+
+### 5.4 当前结算
+
+```text
+订单完成
+ -> 计算号主收入、站点加价、平台利润、服务费和分成
+ -> 写入分销财务和参与方
+ -> 进入站点结算冷却期
+ -> 释放到站点钱包
+ -> 站长发起提现
+```
+
+现有财务拆分能力可复用,但需要重新明确两家公司之间的应付、应收和退款责任。
+
+## 6. 当前隔离能力审计
+
+### 6.1 已有站点字段的表
+
+| 业务 | 表/字段 | 当前情况 |
+|---|---|---|
+| 用户 | `users.distribution_site_id` | 有字段,但手机号全局唯一,登录不校验站点 |
+| 商品货源 | `rental_listings.distribution_site_id` | 表示商品由哪个分站收取,不表示商品只属于该站 |
+| 商品货架 | `distribution_site_accounts.site_id` | 已有站点货架关系和站点加价 |
+| 订单 | `rental_orders.distribution_site_id` | 一个角色锚点字段,不能表达所有参与租户 |
+| 分销财务 | `distribution_order_financials.site_id` | 有站点维度 |
+| 分销参与方 | `distribution_order_parties.site_id` | 用于分成归属 |
+| 钱包 | `wallet_accounts.distribution_site_id` | 已按用户和站点建立独立账户 |
+| 钱包流水 | `wallet_ledger.distribution_site_id` | 已有站点维度 |
+| 提现 | `withdrawal_requests.distribution_site_id` | 已有站点维度 |
+| 公告 | `announcements.distribution_site_id` | 已有站点维度,支持继承主站 |
+| 通知 | `notifications.distribution_site_id` | 部分依赖订单模式自动推断 |
+| 聊天会话 | `chat_conversations.distribution_site_id` | 部分站点隔离 |
+| 二维码 | `chat_qrcode_pool.distribution_site_id` | 主站为 `NULL`,分站为 ID |
+| 邀请 | `user_invitations`、返利配置和流水 | 有站点字段,但邀请码和用户关系仍受全局用户模型影响 |
+| 短信 | `sms_messages.distribution_site_id` | 已有站点维度 |
+
+### 6.2 完全没有完整租户边界的关键表
+
+| 业务 | 表 | 风险 |
+|---|---|---|
+| 用户唯一性 | `users` | 手机号全局唯一,两边不能独立注册 |
+| 实名 | `user_realname` | 身份证全局唯一,两个租户的用户信息是否共享未定义 |
+| 用户收款账户 | `user_payment_accounts` | 仅绑定全局用户,没有租户字段 |
+| 支付单 | `payment_orders` | 没有租户字段,统计和回调回溯只能通过订单间接判断 |
+| 支付商户 | `payment_merchant_configs` | 全局表,通过绑定关系服务站点 |
+| 系统配置 | `system_configs` | 大部分配置全局,只对少数首页入口做站点覆盖 |
+| 客服组 | `chat_support_groups` | 没有租户字段 |
+| 客服组成员 | `chat_support_group_members` | 通过全局管理员关联 |
+| 撞车商品 | `mohong_products`、`mohong_orders` | 没有站点字段 |
+| 线下提号店铺 | `pickup_shops`、`pickup_shop_mappings` | 没有完整站点字段 |
+| 积分商城 | `points_mall_products`、`points_mall_redemptions` | 没有租户字段 |
+| 广告归因 | `ad_tracking_links`、`ad_tracking_clicks` | 没有租户字段 |
+| 客服手工录单 | `cs_manual_entries` | 需要确认是否允许跨租户 |
+| 主站财务 | `manual_disbursements`、`operating_expenses` | 当前是平台级财务视角 |
+
+结论:不能只修改用户和订单两张表。必须明确解忧首期开放哪些业务模块。任何未完成租户隔离的模块都不能直接暴露在解忧。
+
+### 6.3 全局表与平台内部表
+
+以下表可以继续保持平台内部或全局,但必须有明确边界:
+
+- `permissions`
+- `roles`
+- `role_permissions`
+- `admin_users`
+- `audit_logs`
+- `file_upload_owners`
+- 数据库迁移和平台监控
+
+其中 `admin_users` 可以继续全局存储账号,但管理员与租户的访问关系必须通过租户成员表强制验证。
+
+## 7. 关键风险
+
+### 7.1 P0:用户跨站串号
+
+当前:
+
+- `users.phone` 是全局唯一索引。
+- 短信登录按手机号查找全局用户。
+- 密码登录不接收站点编码。
+- 注册只在首次创建时写入站点。
+- 短信验证码 Redis Key 没有站点维度。
+
+后果:
+
+- 解忧用户可能进入旧站账号。
+- 用户可能在解忧看到旧站订单、钱包和通知。
+- 解忧无法给同一手机号建立独立账户。
+- 两个公司的实名、密码和风控状态无法独立。
+
+### 7.2 P0:租户上下文可以被客户端控制
+
+当前站点解析优先级是:
+
+```text
+X-Distribution-Site-Code
+ > X-Site-Code
+ > query 参数
+ > Host 域名兜底
+```
+
+前端还支持 `?site=` 和 `sessionStorage` 保存站点编码。
+
+后果:
+
+- 用户可以伪造租户编码。
+- 页面切站可能导致发布、下单和注册归属错误。
+- 后端无法仅凭 JWT 验证用户是否属于当前网站。
+
+### 7.3 P0:支付跨租户回退
+
+当前站点支付配置不可用时,会回退全局默认商户。
+
+后果:
+
+- 解忧订单可能进入旧站公司账户。
+- 收银台商户名称、退款主体和对账主体可能错误。
+- 对外两个公司,但资金归属不是两个公司。
+
+支付必须失败关闭,而不是回退到其他租户。
+
+### 7.4 P1:商品详情和文件绕过货架
+
+商品列表按货架过滤,但商品详情按 ID 直接读取公开商品。文件接口也按商品 ID 读取截图。
+
+后果:
+
+- 未在解忧货架展示的商品可能通过 ID 被访问。
+- 共享号库可能被误解为“所有租户可以查看所有商品”。
+
+租户货架边界必须覆盖列表、详情、图片、搜索和下单。
+
+### 7.5 P1:订单和统计归属不明确
+
+当前订单主要依赖:
+
+- 一个 `distribution_site_id`
+- 一个 `distribution_mode`
+- 多个参与方分成记录
+
+跨站销售时,一笔订单可能同时出现在两个租户的报表中,但当前逻辑无法明确回答:
+
+- 买方租户是谁
+- 货源租户是谁
+- 支付租户是谁
+- 哪个租户拥有买家客户
+- 哪个租户承担退款
+- 哪个租户应付供货款
+
+必须改为显式多租户字段。
+
+### 7.6 P1:主站运营后台是全局后台
+
+主站运营后台当前可以查看:
+
+- 全量用户
+- 全量实名信息
+- 全量商品
+- 全量订单
+- 全量支付和退款
+- 全量财务
+- 全量客服和争议
+
+如果旧站公司继续使用该后台,而解忧数据写入同一库,旧站管理员可能看到解忧数据。解忧也会面临反向风险。
+
+需要区分:
+
+- 平台内部超级管理员
+- 旧站公司管理员
+- 解忧公司管理员
+- 租户客服
+- 租户财务
+
+### 7.7 P1:系统配置大多全局
+
+当前 `system_configs` 基本是全局配置。只有少量首页内容通过 `distribution_sites` 覆盖。
+
+解忧上线前必须明确哪些配置共享、哪些继承、哪些独立:
+
+- 用户协议
+- 隐私政策
+- 发布协议
+- 订单协议
+- 结算规则
+- 押金和免押规则
+- 成长等级
+- 积分规则
+- 客服规则
+- 节点短信模板
+- 支付开关
+- 页面公告
+
+### 7.8 P2:非租赁业务模块未租户化
+
+撞车商品、积分商城、线下提号、广告归因等模块没有完整站点字段。
+
+建议首期解忧只开放租赁主链路,其余模块通过功能开关隐藏;如需开放,必须先补租户字段和权限测试。
+
+### 7.9 P2:对外品牌与法律主体不一致
+
+当前前台存在“分站 × 主站”的联名逻辑。直接复用会让解忧仍然显示旧站品牌。
+
+两个公司对外展示至少需要独立:
+
+- 网站名
+- Logo
+- 域名
+- ICP
+- 版权
+- 联系方式
+- 隐私政策
+- 用户协议
+- 客服入口
+- 短信签名
+- 支付收银主体
+
+这里不仅是视觉问题,也是支付、退款、投诉和数据合规问题。
+
+## 8. 目标设计
+
+### 8.1 总体目标
+
+```text
+ 共享商品/号库
+ game_accounts + rental_listings
+ │
+ ┌──────────────┴──────────────┐
+ │ │
+ 租户 A:旧站 租户 B:解忧
+ 域名 A 域名 B
+ 用户 A 用户 B
+ 支付 A 支付 B
+ 钱包 A 钱包 B
+ 后台 A 后台 B
+ 统计 A 统计 B
+ │ │
+ └──── 跨租户订单与供货结算 ────┘
+```
+
+### 8.2 设计原则
+
+1. 共享商品,不共享用户、支付、钱包和公司报表。
+2. 旧站也必须是显式租户。
+3. 租户上下文由服务端根据域名确定。
+4. 认证令牌必须绑定租户。
+5. 所有租户私有表必须有不可为空的 `tenant_id`。
+6. 查询租户数据时默认强制加租户条件。
+7. 找不到租户时失败关闭,不回退旧站。
+8. 找不到支付通道时失败关闭,不回退其他公司。
+9. 跨租户订单使用显式买方、货源、支付和用户租户字段。
+10. 公司管理员只能访问所属租户;平台超级管理员另设入口和审计。
+11. 历史订单使用创建时快照,不因租户规则变更重算。
+12. 未完成租户隔离的模块不向新公司开放。
+
+### 8.3 租户主体
+
+建议继续使用现有 `distribution_sites` 作为租户主体,避免全项目重命名表。但必须调整语义:
+
+- 当前旧站创建一条正式租户记录。
+- 解忧创建第二条正式租户记录。
+- 后续代码中的显式租户 ID 可以为旧站 ID 或解忧 ID。
+- 长期不再使用 `0` 表示旧站。
+
+建议增加或明确以下配置:
+
+```text
+tenant_code
+tenant_name
+primary_domain
+status
+legal_entity_name
+icp_no
+copyright
+support_contact
+privacy_policy_ref
+terms_ref
+admin_entry_domain
+timezone
+currency
+default_payment_mode
+```
+
+具体字段名可以后定,但每个租户都必须有稳定的唯一 ID。
+
+### 8.4 租户上下文
+
+目标请求链路:
+
+```text
+请求 Host
+ -> 查询 domain 对应 tenant
+ -> 检查 tenant 状态
+ -> 写入服务端 TenantContext
+ -> 校验 JWT.tenant_id
+ -> 业务查询自动附加 tenant_id
+```
+
+规则:
+
+- 生产环境不信任客户端 `X-Distribution-Site-Code`。
+- 生产环境不允许 `?site=` 切换真实租户。
+- 本地开发可通过显式环境变量或受保护请求头切换,但不能带入生产。
+- 未知域名返回 404。
+- 停用租户返回 503 或维护页。
+- 域名解析失败不能默认进入旧站。
+- 管理端同样需要租户上下文。
+
+### 8.5 用户与认证
+
+建议将用户视为租户私有:
+
+```text
+UNIQUE(tenant_id, phone)
+```
+
+同一手机号可以在两个租户分别注册独立账号。
+
+用户独立范围:
+
+- 密码
+- 昵称和头像
+- 实名状态
+- 风控状态
+- 成长等级
+- 积分
+- 钱包
+- 收款账户
+- 邀请关系
+- 通知
+- 历史订单视图
+
+认证规则:
+
+- 短信验证码 Key 包含租户 ID。
+- 登录失败限流包含租户 ID。
+- JWT 包含租户 ID。
+- 刷新 token 时校验用户仍属于当前租户。
+- 密码重置和注销只作用于当前租户用户。
+- 后台管理员与租户成员关系分开校验。
+
+实名有两种可选策略:
+
+1. 租户私有实名:两个租户分别认证,互不可见。
+2. 平台统一身份:平台保存唯一实名身份,租户用户关联该身份,但公司后台只看到本租户用户投影。
+
+如果两家公司是独立经营主体,首期建议默认“租户私有实名”,避免在未明确授权的情况下共享身份证信息。
+
+### 8.6 商品和货架
+
+共享部分:
+
+- 游戏账号资产
+- 商品主体
+- 商品状态
+- 商品审核结果
+- 库存锁定
+- 商品租用历史
+
+租户相关部分:
+
+- 商品货源租户
+- 商品卖家所属租户
+- 商品在哪些租户货架展示
+- 每个租户的售价和加价
+- 每个租户的商品可见性
+- 每个租户的客服组
+
+建议字段语义:
+
+```text
+rental_listings.source_tenant_id
+rental_listings.owner_user_id
+distribution_site_accounts.tenant_id
+distribution_site_accounts.listing_id
+```
+
+货架规则应覆盖:
+
+- 列表
+- 搜索
+- 详情
+- 截图和封面
+- 推荐区
+- 下单
+- 分享链接
+
+未在当前租户货架展示的商品,访问详情也应返回 404,而不是只隐藏列表。
+
+### 8.7 订单模型
+
+目标订单需要明确以下字段:
+
+```text
+order_id
+sale_tenant_id # 下单和收银的租户
+source_tenant_id # 商品货源租户
+owner_user_tenant_id # 卖家用户所属租户
+renter_user_tenant_id # 买家用户所属租户
+owner_user_id
+renter_user_id
+payment_tenant_id # 实际收款租户
+listing_id
+account_id
+```
+
+订单仍是一份全局业务事实,但需要为不同租户生成不同的订单投影。
+
+买方租户看到:
+
+- 本站订单
+- 本站支付
+- 本站买家账号
+- 本站客服
+- 本站成交和退款
+
+货源租户看到:
+
+- 本租户商品出库
+- 卖家交接
+- 号主收入
+- 对买方租户的供货应收
+
+平台内部看到:
+
+- 完整跨租户订单
+- 两家公司的资金流
+- 应付款和应收款
+- 可能的平台服务费
+
+### 8.8 跨租户交易与公司间结算
+
+共享号库后,最常见的新问题是:
+
+> 解忧卖出了旧站卖家的商品,买家的钱进入解忧,谁给旧站和卖家结算?
+
+必须单独定义公司间结算账,而不是只依赖站点分成金额。
+
+建议新增:
+
+```text
+intercompany_ledger
+ order_id
+ from_tenant_id
+ to_tenant_id
+ amount_cent
+ biz_type
+ status
+ settled_at
+```
+
+典型流:
+
+1. 买家在解忧支付。
+2. 支付款进入解忧商户。
+3. 解忧对买家订单负责退款和客服。
+4. 旧站对卖家交接和号主收入负责。
+5. 解忧向旧站或平台形成供货应付款。
+6. 旧站或平台再向号主结算。
+
+现有 35/40/25 分成规则可以复用为初始财务口径,但需要明确它是否等价于两家公司之间的供货协议。
+
+### 8.9 支付
+
+目标:
+
+- `payment_merchant_configs.tenant_id` 必填。
+- `payment_orders.tenant_id` 必填。
+- `payment_orders.payment_config_id` 必须属于同一租户的订单。
+- 解忧订单只允许使用解忧商户。
+- 找不到商户时返回“暂不可支付”,不回退。
+- 回调根据支付单定位租户和原商户。
+- 退款使用原支付商户。
+- 支付统计按 `payment_orders.tenant_id`。
+- 支付后台按租户过滤。
+
+支付回调流程:
+
+```text
+第三方回调
+ -> 定位 payment_order
+ -> 读取 payment_order.tenant_id
+ -> 加载该租户原支付配置
+ -> 验签
+ -> 幂等更新支付和订单
+ -> 写入该租户与货源租户财务记录
+```
+
+如果两家公司共用同一支付服务商,也必须使用独立商户号、独立密钥或至少独立配置记录,不能共用默认配置。
+
+### 8.10 钱包与提现
+
+现有 `wallet_accounts(user_id, distribution_site_id)` 可以继续使用,但建议明确:
+
+- 用户钱包属于用户所属租户。
+- 租户主体钱包用于公司收入和提现。
+- 跨租户结算单独记公司间账。
+- 提现账户属于租户用户,不能跨站复用。
+- 租户财务只能查看和审核本租户提现。
+
+如果用户已在旧站有钱包,再在解忧注册,应创建解忧独立钱包,不继承旧站余额。
+
+### 8.11 统计口径
+
+“每个网站统计独立”不是给所有查询简单加一个站点条件,而是先定义每项指标的归属。
+
+| 指标 | 归属 |
+|---|---|
+| 注册用户数 | 用户所属租户 |
+| 实名用户数 | 用户所属租户 |
+| 买家订单数 | 买方租户 |
+| 买家成交人数 | 买方租户 |
+| GMV | 支付租户 |
+| 收款金额 | 支付租户 |
+| 退款金额 | 原支付租户 |
+| 商品上架数 | 货源租户 |
+| 本站货架商品数 | 货架所属租户 |
+| 号主收入 | 卖家用户租户 |
+| 跨站供货收入 | 货源租户 |
+| 服务费 | 实际财务规则归属 |
+| 待交接订单 | 当前需要操作的租户投影 |
+
+一笔跨租户订单可以同时进入两家公司的报表,但指标含义不同:
+
+- 解忧看到本站成交、买家支付和买家用户。
+- 旧站看到本商品出库、卖家交接和号主收入。
+- 平台内部总表显示完整订单和公司间结算。
+
+### 8.12 后台与权限
+
+后台层级建议:
+
+1. 平台超级管理员
+ - 跨租户运维
+ - 系统配置
+ - 数据库和备份监控
+ - 强制审计
+ - 不直接服务普通公司运营
+2. 租户管理员
+ - 只管理本租户用户、商品、订单、客服、财务和配置
+ - 不能切换其他租户
+3. 租户客服
+ - 只查看本租户订单投影和客服会话
+4. 租户财务
+ - 只查看本租户支付、钱包、结算和提现
+
+现有分销后台的超级管理员站点切换能力,只能用于平台内部账号,不能提供给两家公司。
+
+租户后台的域名建议:
+
+```text
+admin.旧站域名
+admin.解忧域名
+```
+
+即使复用同一个 React 构建产物,也应通过域名解析固定租户,而不是允许在页面中自由切换。
+
+### 8.13 客服、通知和聊天
+
+现有聊天和通知已有部分站点字段,但仍依赖订单模式推断。
+
+目标规则:
+
+- 每条通知必须直接带 `tenant_id`。
+- 每个客服组必须属于一个租户。
+- 每个客服工单和会话必须有明确租户。
+- 跨租户订单允许买家和卖家通过平台受控会话沟通。
+- 买家租户客服不能自动查看卖家租户的完整实名和联系方式。
+- 客服消息、附件和导出都要租户鉴权。
+
+### 8.14 文件与附件
+
+文件隔离不能只依赖随机对象 Key。
+
+建议:
+
+- 每个租户使用独立对象前缀或租户字段。
+- `file_upload_owners` 增加租户 ID。
+- 上传时绑定租户、上传人和业务场景。
+- 私有文件访问校验租户和业务权限。
+- 公开商品图片也必须校验商品在当前租户的货架可见性。
+- 切站后不能通过旧 URL 继续访问其他租户私有附件。
+
+### 8.15 系统配置
+
+建议将配置分为三类:
+
+| 类型 | 例子 | 处理 |
+|---|---|---|
+| 平台共享 | 支付服务商协议、全局风控、运行参数 | 平台内部维护 |
+| 租户继承 | 默认短信模板、默认客服规则、默认成长规则 | 租户可覆盖 |
+| 租户独立 | 协议、隐私、品牌、支付、客服、结算、公告 | 必须独立 |
+
+所有“继承”都必须显式记录来源,不能因为租户配置缺失就无提示地使用另一家公司的敏感配置。
+
+## 9. 数据模型建议
+
+### 9.1 新增统一租户字段
+
+建议所有租户私有表使用统一字段名,例如 `tenant_id`。如果短期继续沿用 `distribution_site_id`,也应确保语义一致。
+
+不建议一个表同时出现 `site_id`、`distribution_site_id`、`tenant_id` 表示同一概念。
+
+### 9.2 必须带租户 ID 的核心表
+
+- `users`
+- `user_realname`
+- `user_payment_accounts`
+- `payment_merchant_configs`
+- `payment_orders`
+- `wallet_accounts`
+- `wallet_ledger`
+- `withdrawal_requests`
+- `notifications`
+- `chat_conversations`
+- `chat_qrcode_pool`
+- `chat_support_groups`
+- `announcements`
+- `user_invitations`
+- `invitation_rebate_configs`
+- `invitation_rebate_ledgers`
+- `sms_messages`
+- 租户专属系统配置
+- 租户专属文件
+
+### 9.3 需要增加“角色租户”字段的表
+
+- `rental_orders`
+- `rental_listings`
+- `distribution_order_financials`
+- `distribution_order_parties`
+- `distribution_settlement_batches`
+- `disputes`
+- `handoff_records`
+- `process_events`
+- `order_checkouts`
+
+订单相关表不能只有一个租户字段,需要明确角色。
+
+### 9.4 应继续保持共享的表
+
+- `game_accounts`
+- `rental_listings` 的商品和库存主体
+- `listing_status_events`
+- 平台权限定义
+- 平台审计
+- 数据库迁移版本
+
+共享表必须通过关联表控制租户可见性,而不是直接复制商品。
+
+### 9.5 首期建议隐藏的模块
+
+如果解忧首期只做租赁:
+
+- 撞车商品
+- 积分商城
+- 线下提号
+- 广告投放
+- 复杂运营财务
+
+这些模块在完成租户化前不应出现在解忧菜单和公开路由中。
+
+## 10. 方案对比
+
+| 方案 | 隔离强度 | 共享号库 | 改造复杂度 | 适用性 |
+|---|---|---|---|---|
+| 现有分销站简单加品牌 | 低 | 好 | 低 | 只适合同一公司渠道 |
+| 单后端 + 单库 + 强制租户 | 高 | 好 | 中高 | 推荐 |
+| 两个独立后端 + 共享库存 API | 很高 | 中 | 高 | 公司完全独立运营时可选 |
+| 每个租户独立数据库 | 很高 | 困难 | 很高 | 法律或数据库管理要求物理隔离时 |
+| 完整复制两套项目 | 表面高 | 差 | 长期最高 | 不推荐 |
+
+### 10.1 为什么推荐单库多租户
+
+- 库存锁定在同一事务内完成。
+- 跨站订单可以原子创建。
+- 支付、退款、结算和财务快照容易保持一致。
+- 现有代码和数据迁移成本最低。
+- 统计可以直接基于租户字段。
+- 共享商品不需要跨服务同步。
+
+### 10.2 单库多租户的主要风险
+
+- 任意查询漏加租户条件都可能造成数据泄漏。
+- 数据库管理员能看到全部租户数据。
+- 缓存 Key、日志和异步任务必须带租户。
+- 备份恢复按整库执行,不是单租户恢复。
+
+这些风险需要通过:
+
+- 统一租户上下文。
+- 统一查询作用域。
+- 数据库复合索引。
+- 跨租户越权测试。
+- 缓存 Key 租户化。
+- 日志租户化。
+- 代码审查门禁
+
+来控制。
+
+### 10.3 什么情况下应改用独立数据库
+
+满足以下任一条件时,应重新评估独立数据库:
+
+- 两家公司要求数据库管理员也无法互相访问。
+- 两家公司数据不能放在同一个备份中。
+- 两家公司要求独立恢复和独立删除数据。
+- 支付或实名数据必须物理分区。
+- 法规或合同明确要求独立存储。
+
+此时仍应保留中央库存服务,商品发布、库存锁定和跨租户订单通过接口和事件同步,不能复制成两套互不通信的商品库。
+
+## 11. 实施阶段
+
+### 阶段 0:业务规则确认
+
+必须先确认:
+
+- 同一手机号是否允许两边独立注册。
+- 两家公司是否使用独立支付商户。
+- 共享商品是否默认双向铺货。
+- 站点是否可以单方面隐藏共享商品。
+- 跨租户销售如何结算。
+- 退款责任由买方租户还是货源租户承担。
+- 两家公司的客服是否允许查看对方用户信息。
+- 解忧首期开放哪些业务模块。
+- 解忧是否使用独立 ICP、协议、隐私政策和经营主体。
+
+输出:
+
+- 业务规则确认表
+- 财务归属确认表
+- 租户可见性矩阵
+- 数据迁移口径
+
+### 阶段 1:显式租户基础
+
+- 为旧站创建正式租户记录。
+- 创建解忧租户记录。
+- 域名解析改为 Host 权威。
+- 新增租户 Context。
+- 建立统一租户查询作用域。
+- 兼容旧数据中的 `0=主站`,但不新增长期依赖。
+- 未识别域名改为失败关闭。
+
+完成标准:
+
+- 所有请求都能得到明确租户上下文。
+- 伪造请求头不能切换租户。
+- 停用租户不能访问业务。
+
+### 阶段 2:用户、实名和后台权限
+
+- `users` 改为 `(tenant_id, phone)` 唯一。
+- JWT 加入租户。
+- 短信验证码加入租户。
+- 登录、刷新、密码重置校验租户。
+- 实名和收款账户加租户边界。
+- 管理员成员关系绑定租户。
+- 主站全局后台拆分平台后台和租户后台。
+
+完成标准:
+
+- 同一手机号可创建两个独立账号。
+- 旧站 token 无法访问解忧接口。
+- 解忧管理员无法查看旧站用户。
+
+### 阶段 3:商品和货架
+
+- 商品明确货源租户。
+- 货架关系明确租户。
+- 详情、搜索、图片和下单统一检查货架。
+- 缓存按租户隔离。
+- 自动铺货按租户配置执行。
+
+完成标准:
+
+- 未在解忧展示的商品详情和图片均不可访问。
+- 同一商品不能被两个网站同时下单。
+- 站点隐藏不影响其他租户历史订单。
+
+### 阶段 4:订单、支付和退款
+
+- 订单增加买方租户、货源租户、支付租户和用户所属租户。
+- 支付商户和支付单增加租户。
+- 禁止支付回退其他租户。
+- 退款使用原支付商户。
+- 支付回调按支付单定位租户。
+- 通知和客服按租户投影。
+
+完成标准:
+
+- 解忧订单只进入解忧商户。
+- 解忧无商户时不能支付。
+- 退款不会误用旧站商户。
+
+### 阶段 5:财务和统计
+
+- 用户钱包继续按用户和租户隔离。
+- 租户主体钱包按租户隔离。
+- 新增公司间供货结算。
+- 重写统计查询,使用明确租户字段。
+- 租户后台只显示本租户指标。
+- 平台后台单独显示跨租户总账。
+
+完成标准:
+
+- 两边 GMV、支付、退款、用户和利润口径可对账。
+- 跨租户订单在两边展示正确角色信息。
+- 平台总账与两边账不重复不丢失。
+
+### 阶段 6:解忧前台和后台
+
+- 解忧独立域名。
+- 独立 Logo、名称、页脚和客服。
+- 独立协议和隐私政策。
+- 独立后台入口。
+- 独立短信配置。
+- 独立支付配置。
+- 移除联名和旧站品牌。
+- 隐藏未租户化模块。
+
+### 阶段 7:迁移与上线
+
+- 建立旧站租户 ID。
+- 回填用户所属租户。
+- 按订单模式回填买方、货源和支付租户。
+- 支付单通过订单和商户配置回填租户。
+- 通知、聊天、邀请和文件补齐租户。
+- 先在测试库运行完整迁移和回填校验。
+- 新旧统计口径双跑。
+- 选择低峰期切换写入。
+- 保留回滚和备份恢复方案。
+
+### 阶段 8:隔离验收与运营
+
+- 越权测试。
+- 支付对账。
+- 退款演练。
+- 跨租户订单演练。
+- 数据导出和删除演练。
+- 停用租户和域名切换演练。
+
+## 12. 数据迁移注意事项
+
+### 12.1 旧站必须获得显式 ID
+
+迁移时先创建旧站租户记录,得到 `legacy_tenant_id`。
+
+现有 `0` 或 `NULL` 的语义需要按表映射:
+
+- 用户 `0` -> 旧站租户
+- 钱包 `0` -> 旧站租户
+- 通知 `NULL/0` -> 旧站租户或平台通知
+- 公告 `0` -> 旧站租户
+- 主站订单 `NULL/0` -> 旧站买方与货源租户
+
+不能在所有表上机械执行同一条替换规则,因为同一个 `distribution_site_id` 在不同订单模式下可能代表买方、卖方或来源。
+
+### 12.2 订单回填必须按模式推导
+
+示例:
+
+```text
+主站商品 + 主站购买
+ buyer_tenant = 旧站
+ source_tenant = 旧站
+ payment_tenant = 旧站
+
+分站 A 商品 + 主站购买
+ buyer_tenant = 旧站
+ source_tenant = A
+ payment_tenant = 旧站
+
+主站商品 + 分站 B 购买
+ buyer_tenant = B
+ source_tenant = 旧站
+ payment_tenant = B
+
+分站 A 商品 + 分站 B 购买
+ buyer_tenant = B
+ source_tenant = A
+ payment_tenant = B
+```
+
+必须用历史订单和参与方快照交叉校验。
+
+### 12.3 迁移采用兼容窗口
+
+建议顺序:
+
+1. 新增字段,允许为空。
+2. 写入双字段。
+3. 读接口切换为主字段。
+4. 对账历史数据。
+5. 加非空约束和复合索引。
+6. 删除旧分支。
+
+不要在一次大迁移中同时修改表结构、业务逻辑和前端租户识别。
+
+## 13. 验收用例
+
+| 编号 | 场景 | 预期 |
+|---|---|---|
+| T01 | 同一手机号在旧站和解忧分别注册 | 产生两个独立用户,各自密码和实名互不影响 |
+| T02 | 用旧站 JWT 访问解忧受保护接口 | 401/403 |
+| T03 | 伪造 `X-Distribution-Site-Code` | 不改变服务端租户上下文 |
+| T04 | 访问不存在的租户域名 | 404,不进入旧站 |
+| T05 | 访问未在解忧货架展示的商品详情 | 404 |
+| T06 | 访问未在解忧货架展示的商品图片 | 404 |
+| T07 | 解忧订单支付但没有解忧商户 | 支付不可用,不调用旧站商户 |
+| T08 | 解忧支付成功 | 支付单、订单、通知、钱包均记录解忧 |
+| T09 | 解忧订单退款 | 使用原解忧支付商户 |
+| T10 | 解忧管理员查询用户 | 只返回解忧用户 |
+| T11 | 旧站管理员查询支付 | 不返回解忧支付 |
+| T12 | 解忧财务查看提现 | 只能看到解忧提现 |
+| T13 | 旧站商品在解忧售出 | 两边看到符合自身角色的订单投影 |
+| T14 | 跨租户订单完成 | 号主收入、租户收入、公司间应付可对账 |
+| T15 | 两个租户同时下单同一商品 | 只有一个订单成功锁定库存 |
+| T16 | 停用解忧租户 | 解忧域名不可访问,旧站正常 |
+| T17 | 解忧用户访问旧站邀请码 | 不建立跨租户归属 |
+| T18 | 解忧用户查看通知 | 不出现旧站通知 |
+| T19 | 解忧客服查看会话 | 不出现旧站用户会话 |
+| T20 | 导出解忧用户数据 | 只包含解忧用户和业务记录 |
+
+## 14. 测试与门禁建议
+
+### 14.1 静态门禁
+
+- 禁止生产代码直接读取站点请求头作为租户权威来源。
+- 新增租户私有表必须声明租户字段。
+- Repository 查询租户私有表必须带租户条件。
+- 新增公开路由必须声明是否租户相关。
+- 新增支付配置必须有租户。
+- 新增缓存 Key 必须带租户。
+
+### 14.2 集成测试
+
+- 租户 A 和 B 同时存在。
+- 每个跨租户读取接口做反向访问测试。
+- 支付回调和退款按租户验证。
+- 统计结果在迁移前后对账。
+- 跨租户订单双边账务验证。
+
+### 14.3 运维测试
+
+- 单租户停用。
+- 域名改绑。
+- 商户密钥轮换。
+- 单租户支付切换。
+- 回滚。
+- 备份恢复后的租户数据一致性。
+
+## 15. 尚待确认的业务决策
+
+以下问题会直接决定数据模型,建议在开发前逐项确认。
+
+| 决策 | 推荐默认 | 影响 |
+|---|---|---|
+| 同一手机号能否两边独立注册 | 能,两个独立用户 | 决定用户唯一索引和登录模型 |
+| 实名信息是否两边共享 | 默认不共享 | 决定实名表租户化 |
+| 两家公司是否使用独立支付商户 | 必须独立 | 决定支付和退款隔离 |
+| 共享商品是否默认双向展示 | 默认展示,可单站隐藏 | 决定货架铺货规则 |
+| 解忧是否允许采集旧站商品 | 允许,按供货关系结算 | 决定商品货源租户 |
+| 解忧是否允许自己的商品展示到旧站 | 取决于商业合作 | 决定双向铺货 |
+| 解忧卖出旧站商品,钱先到谁 | 先到解忧商户 | 决定支付租户 |
+| 谁负责退款 | 买方租户负责对客退款 | 决定退款流程 |
+| 旧站与解忧如何结算 | 公司间供货应付 | 决定财务模型 |
+| 跨站卖家能否被买家看到手机号 | 默认脱敏,通过平台会话沟通 | 决定客服和隐私 |
+| 两家公司是否独立经营主体 | 需要真实配置 | 决定协议、支付、ICP 和合规 |
+| 解忧首期业务范围 | 只开放租赁主链路 | 避免未隔离模块泄漏 |
+| 平台超级管理员是否可见全部 | 可见,但独立内部后台并审计 | 决定后台分级 |
+| 旧站是否保留全局后台 | 不应直接给公司运营使用 | 决定旧后台拆分 |
+| 租户删除和导出规则 | 每个租户独立 | 决定数据生命周期 |
+
+## 16. 建议的首期范围
+
+为了降低风险,建议解忧首期只做:
+
+- 独立域名
+- 独立品牌和协议
+- 独立注册、登录、实名
+- 共享商品浏览
+- 独立货架和价格
+- 独立下单和支付
+- 独立订单视图
+- 独立钱包和退款
+- 独立客服和通知
+- 独立基础统计
+- 独立后台管理员
+
+首期暂不开放:
+
+- 积分商城
+- 撞车商品
+- 线下提号
+- 广告投放后台
+- 复杂平台财务
+- 跨租户综合运营报表给公司管理员
+
+这样可以先把租赁核心链路的租户边界做正确。
+
+## 17. 主要证据索引
+
+- 用户模型:[user.go](../backend/internal/model/user.go)
+- 用户登录与注册:[auth/service.go](../backend/internal/modules/auth/service.go)
+- 用户仓储:[auth/repository.go](../backend/internal/modules/auth/repository.go)
+- 站点编码解析:[sitecode.go](../backend/internal/sitecode/sitecode.go)
+- 域名兜底:[site_domain_fallback.go](../backend/internal/middleware/site_domain_fallback.go)
+- 分销主体和货架:[distribution.go](../backend/internal/model/distribution.go)
+- 商品公开查询:[public_query.go](../backend/internal/modules/listing/public_query.go)
+- 订单创建:[order/lifecycle.go](../backend/internal/modules/order/lifecycle.go)
+- 订单查询:[order/queries.go](../backend/internal/modules/order/queries.go)
+- 支付配置解析:[payment/repository.go](../backend/internal/modules/payment/repository.go)
+- 支付单:[payment.go](../backend/internal/model/payment.go)
+- 钱包:[wallet.go](../backend/internal/model/wallet.go)
+- 分销统计:[distribution/repository.go](../backend/internal/modules/distribution/repository.go)
+- 主站仪表盘:[admindashboard/repository.go](../backend/internal/modules/admindashboard/repository.go)
+- 公告站点范围:[announcement/repository.go](../backend/internal/modules/announcement/repository.go)
+- 聊天站点过滤:[chat/conversation.go](../backend/internal/modules/chat/conversation.go)
+- 前端站点状态:[distributionSite.ts](../frontend/src/stores/distributionSite.ts)
+- Caddy 部署:[Caddyfile](../deploy/caddy/Caddyfile)
+- 现有分销设计:[分销网管理后台设计方案.md](分销网管理后台设计方案.md)
+- 现有分站收号定价:[分站收号定价分析与改造方案.md](分站收号定价分析与改造方案.md)
+
+## 18. 第一轮分析总结
+
+“解忧”最合理的实现方式不是复制项目,也不是把现有分销站简单换名,而是把现有分销能力升级为共享库存的多租户平台。
+
+最关键的改造不是页面,而是四个边界:
+
+1. 租户身份边界:域名、用户、JWT、后台权限。
+2. 资金边界:支付商户、支付单、退款、钱包和公司间结算。
+3. 库存边界:共享商品、站点货架、详情和文件可见性。
+4. 数据边界:订单角色、用户统计、支付统计和后台查询。
+
+只要这四条边界没有同时完成,就不建议让解忧正式承载真实用户和资金。
+
+下一步建议先把第 15 节的业务决策确认,再形成:
+
+- 租户权限矩阵
+- 数据表改造清单
+- 跨租户订单和财务流
+- 数据库迁移与回填方案
+- 上线验收清单
+
+确认这些内容后再进入数据库迁移和代码实施,返工风险最低。
+
+## 19. 第二轮深度分析结论
+
+### 19.1 对需求的最终理解
+
+这个需求不应被表述为“再开一个分销站”,而应被表述为:
+
+> 建设一个共享租赁货源的内部平台,前台承载两个独立运营主体。旧站和解忧各自拥有独立的客户、账号、支付、钱包、客服、后台和报表;解忧可以销售旧站供货的共享商品;一笔跨公司订单在内部形成供货应付,但对外仍由买方网站独立完成交易和售后。
+
+需要同时满足两组完全不同的目标:
+
+| 视角 | 必须达到的效果 |
+|---|---|
+| 用户视角 | 两个独立域名、独立品牌、独立账号、独立支付、独立订单、独立客服、独立协议;看不出是同一套平台的两个频道 |
+| 内部视角 | 商品和库存只有一份;跨公司订单可追溯;两家公司能各自对账;平台可以处理共享库存、退款和异常 |
+
+不能把“共享号库”理解成“共享账号、共享登录、共享支付或共享统计”。这里共享的只有商品主体、库存状态和必要的履约事实。
+
+### 19.2 首先需要统一一个商业模型
+
+推荐采用“买方网站对客成交、货源网站内部供货”的模型,而不是让用户直接和另一个公司建立买卖合同。
+
+以解忧用户购买旧站商品为例:
+
+- 解忧用户与解忧形成对外交易关系。
+- 解忧负责展示、下单、收款、退款和对客客服。
+- 旧站负责提供商品、完成号主侧交接和商品质量责任。
+- 解忧把全部款项收入自己的支付商户,再按内部供货规则向旧站形成应付。
+- 旧站向自己的号主结算,不直接操作解忧用户的钱包。
+- 两家公司之间用供货单、应付单、退款冲销和对账单解决,不把内部利润分成直接暴露给用户。
+
+这个模型的最大优点是:支付、退款、发票、协议、客服和用户数据都能自然地落在买方租户,不需要让用户在付款时看到另一家公司。
+
+如果实际业务要求“解忧只做导流,旧站直接向用户收款”,那么支付租户将不再是解忧,用户协议、退款责任、数据归属和统计口径都会改变。这不是一个可以后期随手切换的小选项,必须在开发前确定。
+
+### 19.3 现有 `distribution_site_id` 不能直接充当订单租户
+
+当前订单中的 `distribution_site_id` 是分账锚点,不是买方站点。代码会根据买家站点和商品来源站点计算 `distribution_mode`,再让该模式的锚点站点决定规则:
+
+| 当前模式 | 当前锚点 | 当前含义 |
+|---|---|---|
+| `two_party_distributor_receive` | 货源站 | 主站买分站货,分站作为收号参与方 |
+| `two_party_platform_receive` | 买方站 | 分站买主站货,主站作为收号参与方 |
+| `site_self_sell` | 买方站 | 同一分站买本站货 |
+| `three_party` | 买方站 | 两个分站之间买卖,货源站由商品反推 |
+
+支付模块直接使用订单的 `distribution_site_id` 选择商户,因此历史上这个字段还隐含了“支付锚点”的含义。它同时承担了分账锚点、支付归属和查询过滤三种职责,无法再安全承载“买方、货源方、支付方、卖家用户方”四种角色。
+
+新增一个 `tenant_id` 只会把四种角色压缩成一个错误答案。订单必须角色化建模。
+
+### 19.4 推荐的首期落地策略
+
+架构上保留双向供货能力,但首期业务只开放一个方向:
+
+1. 旧站现有库存可铺到解忧。
+2. 解忧用户可以在解忧注册、下单、支付和申请售后。
+3. 解忧自己新增的账号首期只在解忧销售,不反向铺到旧站。
+4. 解忧是首批跨租户订单的买方、收款方和第一责任客服。
+5. 旧站是货源方和履约方,只看到自己负责的供货订单和必要的买家脱敏信息。
+
+首期限制为单向供货,不会妨碍数据模型支持双向交易,却能显著降低以下风险:
+
+- 旧站用户被解忧商品质量、退款和客服流程影响。
+- 两边客服都需要理解对方全部业务规则。
+- 双向跨公司应收应付同时发生,财务对账复杂。
+- 旧站原客户看到解忧商品后产生品牌混淆。
+
+## 20. 两个公司的业务边界与法律边界
+
+### 20.1 对外交易主体
+
+每一笔订单都必须明确一个面向用户的合同卖方,推荐使用 `merchant_of_record_tenant_id` 单独表示。默认规则:
+
+```text
+merchant_of_record_tenant_id = buyer_tenant_id
+payment_tenant_id = buyer_tenant_id
+source_tenant_id = 商品来源租户
+```
+
+只有在法务或支付方案明确要求其他结构时,才允许三者不一致。允许字段不一致不等于允许流程口径模糊;任何不一致都必须在订单快照中记录原因。
+
+### 20.2 两家公司需要各自具备的外部配置
+
+解忧至少需要独立准备:
+
+- 域名和 HTTPS。
+- 公司名称、经营主体、ICP备案信息。
+- 用户协议、隐私政策、租赁规则和售后规则。
+- 支付商户、结算银行卡和回调地址。
+- 客服入口、客服身份、服务时间和投诉渠道。
+- 短信签名和模板。
+- 发票或收据规则,如业务需要。
+- 数据保存期限和用户注销规则。
+
+“共用同一套后端”不能作为免除这些配置的理由。即使用户不知道两个网站共用一个数据库,法律文件仍应真实说明数据由谁处理、共享哪些必要履约信息,以及用户应向哪家公司主张权利。
+
+### 20.3 不能对外暴露的内部信息
+
+解忧前台默认不得出现:
+
+- 旧站品牌、主站后台入口和“分销 / 分站”字样。
+- 平台内部公司间分成比例。
+- 旧站支付商户名称或内部订单资金来源。
+- 旧站号主的手机号、实名信息、收款账户。
+- 平台超级管理员的身份和跨租户统计。
+
+旧站后台默认不得出现:
+
+- 解忧用户的完整个人资料和全部订单历史。
+- 解忧支付流水和钱包余额。
+- 解忧客服会话全文。
+- 解忧公司的客户统计和营销数据。
+
+跨租户订单只向双方暴露履约所需的最小投影。
+
+## 21. 角色化订单模型
+
+### 21.1 订单必填角色
+
+建议订单主表至少增加以下字段:
+
+| 字段 | 含义 | 是否允许不同 |
+|---|---|---|
+| `buyer_tenant_id` | 用户下单和接受服务的网站 | 是 |
+| `source_tenant_id` | 商品及库存所属公司 | 是 |
+| `payment_tenant_id` | 实际创建支付单和收取款项的网站 | 首期应等于买方 |
+| `merchant_of_record_tenant_id` | 对用户承担合同义务的公司 | 首期应等于买方 |
+| `buyer_user_tenant_id` | 买家账号所属租户 | 是 |
+| `seller_user_tenant_id` | 号主账号所属租户 | 是 |
+| `listing_source_tenant_id` | 下单时商品来源租户快照 | 是,必须快照 |
+| `fulfillment_owner_tenant_id` | 当前负责商品交接和账号质量的一方 | 默认等于货源方 |
+| `dispute_owner_tenant_id` | 当前处理售后的第一责任方 | 默认等于买方 |
+
+订单创建时必须验证:
+
+```text
+buyer_user_tenant_id = buyer_tenant_id
+seller_user_tenant_id = source_tenant_id
+listing_source_tenant_id = source_tenant_id
+```
+
+若历史用户或商品存在迁移例外,必须通过受审计的数据修复处理,不能在正常下单路径静默放宽。
+
+### 21.2 为什么不能只用 `buyer_tenant_id + source_tenant_id`
+
+只有买卖双方字段仍然不够:
+
+- 支付方可能因特殊合同不等于买方。
+- 实际履约方可能从货源公司转给平台代管。
+- 售后责任可能在争议升级后转给平台或货源方。
+- 用户所属租户和订单所属租户需要在数据审计中分别证明。
+- 同一用户未来若发生租户迁移,历史订单仍要保留成交时归属。
+
+因此订单既要保留四个核心角色,也要保留用户租户和履约责任快照。
+
+### 21.3 租户可见范围
+
+同一笔跨公司订单应生成两个不同的业务投影,而不是把同一条完整订单给双方后台:
+
+| 信息 | 解忧(买方) | 旧站(货源方) | 平台内部 |
+|---|---|---|---|
+| 订单号 | 可见 | 可见 | 可见 |
+| 商品标题和必要资产信息 | 可见 | 可见 | 可见 |
+| 买家昵称 | 可见 | 脱敏可见 | 可见 |
+| 买家手机号/实名 | 可见,限于本站客服权限 | 默认不可见 | 按审计权限可见 |
+| 号主昵称 | 脱敏可见 | 可见 | 可见 |
+| 号主手机号/实名 | 默认不可见 | 可见 | 按审计权限可见 |
+| 对客实收、退款 | 完整可见 | 只可见供货结算口径 | 完整可见 |
+| 号主应得 | 不可见 | 可见 | 可见 |
+| 公司间应付 | 应付方可见 | 应收方可见 | 完整可见 |
+| 支付商户和渠道流水 | 可见 | 不直接可见 | 完整可见 |
+| 交接账号信息 | 按订单参与者授权 | 按订单参与者授权 | 严格审计可见 |
+
+首期可以把两个投影实现为同一服务上的不同权限 DTO;不要依赖前端隐藏字段。
+
+### 21.4 跨租户履约与聊天是当前方案遗漏的 P1 问题
+
+当前 `chat_conversations` 只有一个 `distribution_site_id`,通知也主要依赖一个 `distribution_site_id` 或订单模式反推。跨公司订单可能同时属于买方和货源方,单个站点字段无法表达以下关系:
+
+- 谁是对客客服。
+- 谁负责交接。
+- 哪个租户可以查看哪些消息。
+- 争议升级后由谁接管。
+- 买家是否能看到货源公司的客服身份。
+
+推荐新增“订单履约案件”视角,把会话绑定到订单和参与角色,而不是只绑定一个网站:
+
+| 角色 | 默认权限 |
+|---|---|
+| 买家 | 查看本订单对客消息和交接结果 |
+| 号主 | 只查看自己需要执行的交接与归还消息 |
+| 买方客服 | 查看对客会话,可向货源方发起内部协作 |
+| 货源客服 | 查看货源履约信息,不自动查看买家实名和支付资料 |
+| 平台仲裁 | 仅在争议升级或审计授权时查看完整信息 |
+
+首期如果复用现有聊天表,至少要增加会话参与租户和角色表,禁止只靠 `distribution_site_id` 决定可见性。
+
+### 21.5 交接凭据必须作为独立敏感数据设计
+
+当前 `handoff_records.content` 和过程日志可以直接保存文本,业务上很可能包含游戏账号、密码或辅助登录说明;这些表目前没有独立租户、加密或按订单角色访问的设计。跨公司交易会扩大暴露面。
+
+建议:
+
+- 将账号密码类交接内容存为独立 `order_secrets`,使用字段级加密。
+- 只允许买家和当前履约责任人查看,客服需额外权限并记录审计。
+- 默认到期隐藏或删除可读明文;历史审计只保留操作事实。
+- 禁止写日志、导出表格、搜索索引和普通客服列表。
+- 交接记录中的截图、聊天附件同样执行订单角色鉴权。
+
+这不是“锦上添花”的安全项。两家公司共享货源后,用户问“另一个公司的人为什么能看到我的手机号或账号密码”会直接成为运营与合规问题。
+
+## 22. 全量数据归属矩阵
+
+下表按业务能力列出当前 71 张业务表的处理建议。`新表` 指现有结构无法表达目标语义,需要通过迁移新增。
+
+| 数据组 | 当前主要表 | 当前状态 | 目标处理 |
+|---|---|---|---|
+| 租户主体与品牌 | `distribution_sites` | 已有站点、域名、品牌、短信、结算冷却 | 作为显式租户主体;补齐法律主体、状态、时区和数据保留策略 |
+| 租户域名 | 仅 `distribution_sites.domain` | 每站一个域名,后台域名由部署配置固定 | 建议新增 `tenant_domains`,支持主站、H5、管理后台、别名域名和域名类型 |
+| 租户后台成员 | `distribution_site_members` | 已有成员关系 | 保留并绑定显式租户;禁止一个普通租户管理员通过切换站点访问其他租户 |
+| 租户货架 | `distribution_site_accounts` | 已有逐站展示和价格差值 | 保留;增加来源租户快照、可见性策略和同商品唯一约束 |
+| 后台账号 | `admin_users` | 用户名全局唯一,JWT 无租户 | 租户管理员用户名改为 `(tenant_id, username)`;平台管理员独立入口和身份域 |
+| 角色和权限 | `roles`、`permissions`、`admin_user_roles`、`role_permissions` | 定义和授权均偏平台全局 | 权限定义可共享;角色和成员授权必须带租户或明确标记平台级 |
+| 用户 | `users` | 有 `distribution_site_id`,手机号全局唯一 | 改为显式 `tenant_id`,唯一键改为 `(tenant_id, phone)`;JWT 和刷新令牌带租户 |
+| 实名 | `user_realname` | 无租户,身份证哈希全局唯一 | 加租户;身份唯一键改为 `(tenant_id, id_no_hash)`,是否跨租户复用需单独决策 |
+| 收款账户 | `user_payment_accounts` | 无租户,只按用户查 | 加租户;同一租户内保留默认账户规则 |
+| 成长与积分 | `renter_growth_ledger`、用户上的成长字段 | 无租户,按用户聚合 | 按用户租户隔离;跨站订单不能给买家账户累计另一公司的成长权益 |
+| 积分商城 | `points_mall_products`、`points_mall_redemptions` | 商品和兑换均全局 | 首期隐藏;开放前给商品、兑换和库存加租户 |
+| 邀请关系 | `user_invitations`、邀请字段 | 有站点字段但邀请码全局唯一 | 邀请码按租户唯一;邀请人和被邀请人必须同租户 |
+| 邀请返利 | `invitation_rebate_configs`、`invitation_rebate_ledgers` | 已有站点字段 | 保留;明确成本由买方还是货源方承担,不能根据利润表猜 |
+| 游戏账号 | `game_accounts` | 共享,无来源租户 | 增加 `source_tenant_id`;号主租户从创建时固化,不依赖当前用户表推断 |
+| 租赁商品 | `rental_listings` | 有可空 `distribution_site_id`,`NULL` 表示主站 | 增加非空或统一默认的来源租户;保存商品来源快照和供货可见策略 |
+| 商品生命周期 | `listing_status_events` | 无租户,不是财务事件 | 可继续按商品共享;查询和导出时通过商品来源租户鉴权 |
+| 商品编号 | `listing_no_sequences` | 平台全局序列 | 可继续共享;若两边要求完全看不出关联,可改为租户前缀 + 本地序列 |
+| 外部上传 | `listing_uploads` 及其关联字段 | 无租户,存在来源渠道和管理员 | 加来源租户、上传人租户和渠道;导入接口必须绑定可信域名或签名来源 |
+| 文件 | `file_upload_owners` 及对象存储路径 | 主要按用户授权,无租户 | 加租户、业务场景和订单角色;租户管理员默认看不到另一租户私有文件 |
+| 货架规则 | `distribution_rule_versions` | 已有站点维度;允许全局规则回退 | 保留版本化;跨公司规则不得静默回退到另一公司的费率 |
+| 订单主表 | `rental_orders` | 单 `distribution_site_id` 兼作锚点和支付选择依据 | 新增买家、货源、支付、合同卖方、用户租户和履约责任字段;保留旧字段兼容期 |
+| 订单角色投影 | 当前无独立授权表 | 通过 `owner_id`、`renter_id` 和模式条件拼接 | 新增 `order_tenant_roles` 或等价投影表,统一后台可见范围 |
+| 结算单 | `order_checkouts` | 无租户,只能通过订单反查 | 通过订单角色鉴权;财务导出增加买卖双方角色字段 |
+| 交接记录 | `handoff_records` | 无租户,内容可能含凭据 | 通过订单角色鉴权;凭据迁移到加密敏感表 |
+| 流程事件 | `process_events` | 无租户、无访问侧 | 增加 `tenant_id`、`actor_tenant_id` 和可见角色;平台事件和业务事件分开 |
+| 争议 | `disputes` | 无租户,平台仲裁为主 | 增加买方责任租户、货源责任租户和处理队列;平台仲裁账号不能下放给普通租户 |
+| 支付商户 | `payment_merchant_configs` | 无商户归属租户,可被多站绑定 | 增加 `owner_tenant_id`;租户只能使用自己商户,平台代收需独立标记 |
+| 支付单 | `payment_orders` | 无租户,只关联订单、用户、配置 | 增加 `tenant_id`、`merchant_owner_tenant_id`;商户和配置做不可变快照 |
+| 支付配置日志 | `payment_config_usage_logs` | 无租户 | 增加租户和订单角色;只供平台和商户所属租户审计 |
+| 支付通道绑定 | `distribution_site_payment_channels` | 可把任意商户绑定给站点 | 绑定前校验商户归属;禁止跨公司使用未授权商户 |
+| 用户钱包 | `wallet_accounts`、`wallet_ledger` | 已按用户 + 站点隔离 | 显式化租户;禁止 0 表示主站的长期特例 |
+| 提现 | `withdrawal_requests` | 已有站点字段 | 加审查租户、付款租户和银行流水归属;租户财务只能看本站 |
+| 分销财务 | `distribution_order_financials`、`distribution_order_parties` | 单锚点、平台/站点两类参与方 | 改为角色化快照;公司间应收应付另建账,不能只存利润比例 |
+| 分销结算 | `distribution_settlement_batches`、`distribution_settlement_items` | 单站点批次,内部钱包转账 | 增加付款租户、收款租户、合同/供货单号;支持冲销和部分退款 |
+| 公司账本 | 当前缺失 | 无独立公司间真实账务 | 新增 `tenant_ledger_entries`、`intercompany_documents`、`intercompany_reconciliation` |
+| 通知 | `notifications` | 一个站点字段,部分依赖订单模式推断 | 明确 `recipient_user_id`、`recipient_tenant_id`、`source_tenant_id` 和可见角色 |
+| 后台通知 | `admin_notifications` | 只关联管理员 | 增加租户或通过管理员成员关系强校验;平台通知独立类型 |
+| 客服分组 | `chat_support_groups`、成员表 | 分组全局,成员全局 | 分组加租户;租户管理员只能分配本站客服 |
+| 客服会话 | `chat_*` 系列表 | 会话一个站点字段,消息继承会话 | 普通客服会话按租户;跨公司订单使用订单履约案件和角色可见性 |
+| 快捷回复与推送 | `chat_quick_replies`、`push_channels`、`push_rules` | 全局或个人,推送规则全局 | 按租户配置;平台级通道密钥与租户业务模板分开 |
+| 二维码池 | `chat_qrcode_pool`、交付任务 | 有可空站点字段 | 按租户或平台共享池明确类型;跨订单交付记录绑定来源租户 |
+| 系统配置 | `system_configs` | 全局 `key` 唯一 | 改为平台配置 + 租户覆盖两层;敏感配置禁止跨租户继承 |
+| 公告与首页 | `announcements`、站点 JSON 配置 | 已有站点字段 | 保留;默认只显示本站公告,不能自动混入另一公司公告 |
+| 短信 | `sms_messages`、站点短信配置 | 有可空站点字段,失败时可回退主站通道 | 每条显式绑定租户和发送身份;跨公司短信不得回退另一公司的签名 |
+| 广告归因 | `ad_tracking_links`、`ad_tracking_clicks` | 全局 | 加租户和投放主体;短链解析不能修改其他租户归因 |
+| 客服录单 | `cs_manual_entries`、`cs_manual_entry_options` | 全局唯一编号和选项 | 首期隐藏;开放前按租户分配编号空间和选项 |
+| 线下提号 | `admin_pickups`、`pickup_shops`、映射表 | 全局平台运营 | 首期不在解忧开放;完成租户化且明确库存归属后再开放 |
+| 摸红商品 | `mohong_products`、`mohong_orders` | 全局业务模块 | 首期隐藏;有独立供应商和结算规则时再租户化 |
+| 运营财务 | `manual_disbursements`、`operating_expenses`、财务调整表 | 全局 | 每笔费用明确租户;平台公共成本单列,不自动分摊给两家公司 |
+| 审计日志 | `audit_logs` | 平台全局,缺少统一租户字段 | 增加租户、操作者和跨租户标记;平台审计不能直接开放给租户后台 |
+| 平台基础表 | 权限定义、迁移版本、全局序列 | 适合共享 | 保持平台级;不得混入租户业务数据 |
+
+这张矩阵最重要的结论是:`users`、`payment_merchant_configs` 和 `system_configs` 目前都没有真正独立的租户边界;只给订单加站点字段无法解决它们。
+
+## 23. 资金、退款和公司间结算
+
+### 23.1 实收不等于收入
+
+解忧商户收到用户 100 元,不代表解忧直接确认 100 元收入。推荐拆成以下事实:
+
+| 事件 | 解忧(买方/收款方) | 旧站(货源方) | 号主 |
+|---|---|---|---|
+| 用户支付 G | 现金增加 G;形成用户预收款负债 | 无现金变化 | 无变化 |
+| 订单完成,应给货源方 S | 预收款减少 G;确认收入约 `G-S-通道费`;形成对旧站应付 S | 形成对解忧应收 S;形成对号主应付 O;确认自身毛利 `S-O` | 形成钱包应收 O |
+| 退款 R | 原路退回;冲减收入和应付;必要时形成对旧站追偿 | 冲减应收和号主应得 | 尚未发放则取消,已发放则冲销或追偿 |
+| 通道手续费 C | 由实际收款租户承担;计入费用 | 不承担解忧商户手续费 | 不承担 |
+
+这里的 `S` 必须来自明确的供货结算规则,不能直接使用现有 35% / 40% / 25% 利润分成字段。现有逻辑把 35%/40%/25% 当作利润拆分比例,并且明确“通道手续费由主站承担,只记录”,适合单公司渠道,不适合两个公司的真实账务。
+
+### 23.2 现有分账逻辑需要拆成两层
+
+建议保留两层,但不要混为一张表:
+
+1. **对客价格与退款层**:用户实付、租金、押金、优惠、退款、通道费。
+2. **公司间供货与结算层**:旧站供货价、解忧服务收入、号主应得、平台服务费、应付/应收、冲销。
+
+现有 `distribution_order_financials` 可以作为用户订单财务快照继续演进,但 `distribution_order_parties` 不能继续承担所有公司和平台的利润语义。新增公司间单据至少需要:
+
+- `intercompany_supply_orders`:一笔跨租户订单对应的供货单。
+- `intercompany_ledger_entries`:应收、应付、退款冲销和实际打款。
+- `intercompany_reconciliations`:双方确认的对账批次。
+- `platform_service_fees`:若平台独立收费,单独记录服务方、承担方和计税口径。
+
+若两家公司的业务规模还不大,可以先用一张规范化总账加单据类型,不必马上引入完整会计系统;但每一笔跨公司金额都必须有来源订单、责任租户、付款租户、收款租户和冲销关系。
+
+### 23.3 退款责任
+
+首期推荐以下规则:
+
+1. 用户只向解忧申请退款。
+2. 解忧必须使用原支付单和原支付商户退款。
+3. 解忧不能因为自己商户不可用而使用旧站商户退款。
+4. 退款完成后,系统按订单费用快照自动生成公司间应付冲销。
+5. 如果退款由货源质量问题导致,旧站承担对应供货款和号主款冲销。
+6. 如果已经向号主结算,旧站负责从其用户钱包或后续收入中追偿;不能直接扣解忧用户账户。
+7. 争议、押金暂扣和线下补款必须保留独立的状态与证据。
+
+首期不要自动实现复杂跨公司追偿。先把“原路退款、应付冲销、人工仲裁”做正确,再扩展自动分摊。
+
+### 23.4 押金和优惠
+
+押金是负债,不应计入 GMV 或公司收入。跨租户订单需要明确:
+
+- 押金由买方租户收取和退还。
+- 押金转消费、损坏扣除或补偿时,才根据规则转为某一方的收入。
+- 买方的成长等级免押与优惠不能消耗货源租户的权益。
+- 货源租户的号主补贴、保险和赔偿规则必须体现在供货价或独立补偿单中。
+
+### 23.5 统计不得重复计算
+
+建议把指标分成四套口径:
+
+| 口径 | 旧站报表 | 解忧报表 | 平台总报表 |
+|---|---|---|---|
+| 用户/GMV/支付 | 仅旧站买方订单和旧站用户 | 仅解忧买方订单和解忧用户 | 按买方租户汇总,可重复展示分组但不做跨租户相加后的“公司GMV” |
+| 供货/出库 | 旧站作为货源的供货单和号主收入 | 解忧作为货源时才展示 | 全部货源订单 |
+| 公司收入 | 旧站自身成交 + 对外供货收入 | 解忧自身成交利润 | 内部交易抵消后的平台合并口径 |
+| 应收应付 | 对解忧应收等 | 对旧站应付等 | 内部往来必须抵消,不能把公司间转账算成新增 GMV |
+
+跨租户订单可以同时出现在两家公司报表中,但指标名称必须不同,例如“本站成交额”“本商品供货额”“号主应结算额”,不能都叫“成交额”。
+
+## 24. 认证、后台和异步任务的隔离
+
+### 24.1 用户认证目标规则
+
+| 场景 | 目标行为 |
+|---|---|
+| 访问解忧注册 | Host 解析为解忧租户;验证码写入解忧租户键 |
+| 同一手机号在旧站注册 | 创建旧站账号;不覆盖解忧账号 |
+| 解忧短信登录 | 只按 `(解忧租户, 手机号)` 查用户 |
+| 密码登录 | 请求 Host 或令牌租户必须与账号租户一致 |
+| 刷新令牌 | 校验令牌租户与当前网站租户;不允许跨域刷新后继续访问 |
+| 登出 | 只撤销当前账号令牌;不能依赖手机号全局操作 |
+| 短信重发与风控 | 验证码按租户隔离;防刷限额可叠加一个平台全局手机号限额 |
+
+JWT 至少应包含 `tenant_id`、`subject_type`、`user_id`、`token_version`。认证中间件应把“当前请求租户”和“令牌租户”做相等校验。Cookie 天然按域名隔离,但服务端仍必须校验声明,不能把浏览器隔离当安全边界。
+
+### 24.2 后台认证目标规则
+
+平台超级管理员和租户管理员应属于不同身份域:
+
+- 平台管理员使用独立后台域名和独立登录入口。
+- 租户管理员只能登录自己的后台域名。
+- 租户管理员用户名唯一键为 `(tenant_id, username)`,或使用独立 `tenant_admin_users`。
+- 租户管理员 JWT 包含租户;不能通过修改请求头、query 或前端站点选择器切换。
+- 角色权限可以复用定义,但授权数据必须绑定租户。
+- “切换站点”能力只提供给平台审计账号,并记录每次跨租户访问。
+
+当前分销管理后台登录后读取第一个成员站点,以及多个站点共用 `admin_users` 的方式,不适合两个公司正式使用。两家公司的后台账号必须相互不可登录、不可枚举。
+
+### 24.3 请求租户必须由服务端决定
+
+生产环境推荐:
+
+```text
+请求 Host -> tenant_domains 映射 -> tenant context
+```
+
+具体规则:
+
+- 只信任 Caddy 透传且未被客户端覆盖的原始 Host。
+- 未识别、停用或域名冲突时失败关闭,返回 404/503,不能回落主站。
+- `X-Distribution-Site-Code` 只在开发环境允许模拟租户。
+- query 参数不能在生产环境切换租户。
+- 后台域名和前台域名分别绑定同一租户,不允许前台域名登录后台后跨租户。
+- 支付回调等无业务 Host 的入口,先按支付单号定位租户,再验签和处理,禁止按回调 Host 选择商户。
+
+### 24.4 查询隔离方式
+
+MySQL 没有像 PostgreSQL RLS 那样可靠的行级安全默认能力,因此需要在应用层建立强约束:
+
+- 每个租户业务仓储方法都显式接收 `TenantScope`。
+- 禁止提供无租户参数的通用 `ListAll` 给租户后台。
+- 对外 API 使用买方租户。
+- 对账和平台后台使用显式平台权限和跨租户审计。
+- 所有租户表建立 `(tenant_id, status, created_at)`、`(tenant_id, user_id)` 等复合索引。
+- 缓存 Key、异步消息、任务日志、指标标签必须带租户。
+- DTO 序列化前检查字段级权限,不能只检查是否命中主表。
+
+### 24.5 异步任务和通知
+
+订单超时、结算释放、短信发送、退款重试和文件清理都是平台任务,不能人为要求每个请求都带 Host。正确做法是:
+
+1. 任务从数据库读取明确的 `tenant_id` 或订单角色。
+2. 任务内部构造显式租户上下文。
+3. 每个收件人按 `recipient_tenant_id` 生成通知。
+4. 短信签名、客服分组和通知模板按发送方租户选择。
+5. 无法解析租户的记录暂停处理并告警,禁止默认按旧站发送。
+
+## 25. 数据迁移与兼容策略
+
+### 25.1 使用扩展、回填、收缩三段式迁移
+
+不要直接在一个发布版本里把 `0=主站` 改成外键,也不要先删除旧唯一索引。推荐:
+
+1. **扩展**:新增显式租户、角色字段、复合索引和新表;旧代码继续可写。
+2. **回填**:按可证明规则填历史和新增数据;进入双读校验。
+3. **切换**:新代码只写显式字段,旧字段进入只读兼容。
+4. **收缩**:连续观察一个完整结算周期后,再删除旧唯一键和兼容逻辑。
+
+### 25.2 旧站必须获得固定租户 ID
+
+迁移第一步创建旧站正式租户,并把以下值统一映射到该 ID:
+
+- `users.distribution_site_id = 0`
+- `rental_listings.distribution_site_id IS NULL`
+- `wallet_accounts.distribution_site_id = 0`
+- `notifications.distribution_site_id IS NULL OR = 0`
+- 其他历史 `NULL / 0 = 主站` 的字段
+
+主站租户 ID 一旦确定,不得因为数据库重建、测试环境或再开通站点而变化。
+
+### 25.3 历史订单角色回填建议
+
+历史订单不能简单把 `distribution_site_id` 复制到买方租户。可按以下原则推导,再抽样人工核对:
+
+| 历史模式 | 买方租户 | 货源租户 | 历史支付租户 | 备注 |
+|---|---|---|---|---|
+| 空模式且无站点 | 旧站 | 商品来源租户 | 实际支付配置归属 | 主站内部订单 |
+| `two_party_distributor_receive` | 旧站 | `distribution_site_id` 对应站点 | 现有实现通常按该站点配置支付 | 不能把旧主站买家改成货源站 |
+| `two_party_platform_receive` | `distribution_site_id` 对应站点 | 旧站 | 该站点 | 常见的解忧买旧站货历史形态 |
+| `site_self_sell` | 站点 | 同一站点 | 该站点 | 本站内部订单 |
+| `three_party` | `distribution_site_id` 对应买方站点 | 通过 `listing_id` 反查商品来源 | 需按支付配置核对 | 现有锚点不能证明货源方 |
+
+历史支付租户必须优先从 `payment_orders.payment_config_id`、实际商户归属和支付通道绑定反查。若历史商户被多个站点共享,应标记为 `legacy_shared`,交由财务抽样确认,不能凭空假定。
+
+### 25.4 唯一键迁移顺序
+
+以用户手机号为例:
+
+1. 新增可空 `tenant_id` 并回填。
+2. 校验无空值、无非法租户。
+3. 新增 `(tenant_id, phone)` 唯一索引。
+4. 双读验证按租户查询不会再命中其他租户。
+5. 切换登录、注册和刷新逻辑。
+6. 观察后删除旧的 `phone` 全局唯一索引。
+
+实名身份证、后台用户名、邀请码也采用同样顺序。不能在业务切换前先放开全局唯一约束,否则会产生无法自动判定归属的重复数据。
+
+### 25.5 上线顺序建议
+
+建议采用五个阶段,而不是一次性全量上线:
+
+1. **影子验证**:新字段双写、旧逻辑不变,后台对比订单角色和财务归属。
+2. **内部租户演练**:用测试域名和测试商户完成注册、下单、支付、退款、结算和客服全链路。
+3. **解忧小流量**:只开放旧站供货给解忧,关闭未租户化模块,限制首批用户。
+4. **财务对账**:至少跑完一个结算冷却周期,核对实收、退款、号主结算和公司间应付。
+5. **正式放量**:确认没有跨租户泄漏后,再开放更多商品、营销和后台能力。
+
+每一步都必须有功能开关、域名级回滚和未完成订单处置方案。数据库回滚不能依赖“删除解忧数据”,因为支付和退款一旦发生就必须保留。
+
+## 26. 修订后的实施优先级
+
+### 26.1 P0 上线阻断项
+
+以下事项任何一项未完成,都不应开放真实支付:
+
+1. 生产租户只按可信 Host 解析,未知域名失败关闭。
+2. 用户、JWT、短信验证码和刷新令牌带租户;同一手机号可建立两个独立账号。
+3. 订单显式保存买方、货源、支付、合同卖方和用户租户角色。
+4. 支付商户明确归属租户;买方无可用商户时直接失败,不回退旧站。
+5. 退款只使用原支付商户,并生成公司间冲销。
+6. 跨公司订单的信息投影和客服权限已实现,不能把完整订单给双方。
+7. 账号交接敏感内容已加密并按订单角色授权。
+8. 两边后台不能通过请求头、query、站点 ID 或成员关系越权查看对方数据。
+
+### 26.2 P1 正式运营前完成项
+
+- 全量报表改为角色口径,尤其区分买家订单、货源供货和号主收入。
+- 解忧独立公告、协议、短信、客服组、支付配置和通知模板。
+- 商品详情、图片、搜索、筛选和文件访问统一校验货架归属。
+- 所有租户后台导出、批量操作和审计日志检查租户边界。
+- 租户停用、用户注销、数据导出和保留期限流程。
+- 公司间对账单和退款冲销的财务确认流程。
+
+### 26.3 P2 后续增强项
+
+- 双向共享库存。
+- 租户级独立数据库或独立备份。
+- 自动清算和银行对账。
+- 多个品牌共享同一公司租户。
+- 独立应用服务和事件总线。
+- 平台成本自动分摊。
+
+### 26.4 建议的首期明确不做
+
+- 不复制两份后端代码。
+- 不让两个站点共享用户、JWT、支付商户或钱包余额。
+- 不在首期开放解忧商品反向供给旧站。
+- 不在首期开放积分商城、摸红、线下提号、广告后台和复杂运营财务。
+- 不让普通分销管理员保留“跨站切换”能力。
+- 不把“加一个 Logo”当作租户改造完成标准。
+
+## 27. 仍需最终确认的业务决策
+
+以下决策会改变数据模型或财务流程,建议按此顺序确认:
+
+| 顺序 | 决策 | 推荐答案 | 如果不这样选择的影响 |
+|---|---|---|---|
+| 1 | 谁来对解忧用户承担合同卖方责任 | 解忧 | 协议、发票、退款和支付商户都要重做 |
+| 2 | 解忧购买旧站商品,钱进哪个商户 | 解忧商户 | 若进旧站商户,则解忧不是实际收款方,统计和披露会改变 |
+| 3 | 首期是否只做旧站向解忧单向供货 | 是 | 双向供货需要同时开放两岸客服、售后和应付流程 |
+| 4 | 同一手机号是否可在两个站独立注册 | 是,两个独立账号 | 若共享账号,就不是独立用户域,租户隔离边界失效 |
+| 5 | 实名是否跨租户共享 | 默认不共享 | 若共享,需要用户明确授权、统一保留期限和跨公司访问审计 |
+| 6 | 解忧用户的成长积分和优惠是否独立 | 独立 | 跨站共享会把一家公司的营销成本转嫁给另一家 |
+| 7 | 跨站订单的号主收入记在哪 | 货源租户钱包 | 记在买方会形成跨公司用户资金混用 |
+| 8 | 支付通道费由谁承担 | 实际收款租户 | 沿用“主站承担”会使解忧利润失真 |
+| 9 | 公司间供货价如何确定 | 每单快照,可按商品或类目配置 | 只靠利润比例无法形成法律和财务应付 |
+| 10 | 退款由谁对客处理 | 买方租户 | 货源方直接退款会暴露另一公司商户和主体 |
+| 11 | 货源质量问题是否自动追偿 | 首期人工确认,自动生成冲销待办 | 全自动追偿容易造成错误扣款 |
+| 12 | 买家能否看到号主手机号和实名 | 默认不能,仅通过受控订单会话 | 共享号库会增加跨公司个人信息暴露 |
+| 13 | 两家公司后台是否允许查看对方用户 | 默认不允许,平台内部后台可审计查看 | 若允许,必须提供明确角色、目的和日志 |
+| 14 | 哪一方负责跨站争议 | 买方先行、货源协作、平台仲裁 | 无责任归属会导致用户和号主互相推诿 |
+| 15 | 是否需要独立 ICP、支付商户和隐私政策 | 需要 | 共用主体或政策会破坏“两个公司网站”的外部事实 |
+| 16 | 是否需要物理数据库隔离 | 当前不需要 | 如合同要求 DBA 也不可互访,应改为独立库加中央库存服务 |
+| 17 | 解忧首期开放哪些模块 | 仅租赁主链路 | 未租户化模块会直接形成越权和财务缺口 |
+
+建议先确认第 1~5 项和第 7~9 项。它们决定订单主模型、支付模型、实名唯一键和公司间账务,后期返工成本最高。
+
+## 28. 第二轮新增证据索引
+
+- 分账模式判定:[lifecycle.go](../backend/internal/modules/distribution/lifecycle.go)
+- 分账计算与手续费口径:[pricing.go](../backend/internal/modules/distribution/pricing.go)
+- 支付单按订单单站点选商户:[payment_start.go](../backend/internal/modules/payment/payment_start.go)
+- 支付商户回退:[repository.go](../backend/internal/modules/payment/repository.go)
+- 订单创建与来源快照:[lifecycle.go](../backend/internal/modules/order/lifecycle.go)
+- 通知按订单模式反推站点:[repository.go](../backend/internal/modules/notification/repository.go)
+- 会话单站点过滤:[conversation.go](../backend/internal/modules/chat/conversation.go)
+- 交接记录明文内容:[handoff.go](../backend/internal/modules/order/handoff.go)
+- 身份证全局唯一:[repository.go](../backend/internal/modules/realname/repository.go)
+- 收款账户无租户:[repository.go](../backend/internal/modules/paymentaccount/repository.go)
+- 后台管理员全局用户名:[admin_user.go](../backend/internal/model/admin_user.go)
+- 分销成员登录读取首个站点:[repository.go](../backend/internal/modules/distribution/repository.go)
+- 系统配置全局键:[repository.go](../backend/internal/modules/systemconfig/repository.go)
+- 邀请返利配置和来源:[invitation.go](../backend/internal/model/invitation.go)
+- 积分商城无租户:[points_mall.go](../backend/internal/model/points_mall.go)
+- 文件所有权:[file_upload.go](../backend/internal/model/file_upload.go)
+
+## 29. 最终建议
+
+当前项目可以支撑“渠道分站”,但距离“两个独立公司共用号库”还缺少四类基础能力:可信租户身份、角色化订单、租户化资金账务、跨公司履约与数据授权。
+
+推荐最终落地为:
+
+1. 单后端、单主库,旧站和解忧都是显式租户。
+2. 商品和库存共享,租户货架和价格独立。
+3. 买方租户对客收款、退款、开票和承担第一责任客服。
+4. 货源租户提供库存、交接和号主结算。
+5. 支付商户严格归属,任何跨租户回退都失败关闭。
+6. 跨公司订单使用买方/货源/支付/合同卖方多角色建模。
+7. 用户信息、实名、钱包、后台权限、客服和统计按租户隔离。
+8. 跨公司资金使用独立应收应付账,不把利润比例当真实结算。
+9. 首期只开放旧站向解忧单向供货,完成验收后再考虑双向。
+10. 在真实支付和退款链路全部通过跨租户测试前,不解忧正式对外运营。
+
+下一步应输出一份独立的实施设计稿,把本文第 21~25 节的方案细化为:租户权限矩阵、精确字段和索引变更、历史订单回填 SQL、跨租户状态机、公司间对账规则、测试用例和上线回滚清单。当前阶段仍不建议直接改业务代码或执行迁移。
diff --git a/frontend/src/features/admin/api/paymentConfig.ts b/frontend/src/features/admin/api/paymentConfig.ts
index 17a88ff3..431efaff 100644
--- a/frontend/src/features/admin/api/paymentConfig.ts
+++ b/frontend/src/features/admin/api/paymentConfig.ts
@@ -19,10 +19,7 @@ export interface PaymentConfig {
status: string
environment: string
business_tags: string[] | null
- reference_count: number
- total_transactions: number
- total_amount_cent: number
- last_used_at: string | null
+ has_payment_records: boolean
created_by: number | null
updated_by: number | null
created_at: string
diff --git a/frontend/src/features/admin/views/AdminListingsView.vue b/frontend/src/features/admin/views/AdminListingsView.vue
index e2311c1d..ccfbc6d1 100644
--- a/frontend/src/features/admin/views/AdminListingsView.vue
+++ b/frontend/src/features/admin/views/AdminListingsView.vue
@@ -62,7 +62,8 @@ const publishOptions = ref(emptyListingPublishOptions)
const listingColumnUnits = {
code: 68,
zone: 70,
- region: 100,
+ region: 62,
+ login: 38,
core: 80,
level: 58,
equipment: 78,
@@ -161,10 +162,11 @@ const screenshotColumns = [
read: (row: Listing) => zoneCompensationText(row),
},
{
- label: '地区/上号/纯币',
- width: 140,
- read: (row: Listing) => regionLoginCoinText(row),
+ label: '地区/纯币',
+ width: 105,
+ read: (row: Listing) => regionCoinText(row),
},
+ { label: '上号方式', width: 70, read: (row: Listing) => loginMethodText(row) },
{
label: '保险/体力/负重',
width: 112,
@@ -179,8 +181,8 @@ const screenshotColumns = [
{ label: '皮肤资产', width: 189, read: (row: Listing) => skinAssetText(row) },
{ label: '人脸/全英雄', width: 81, read: (row: Listing) => faceHeroText(row) },
{ label: '号主备注', width: 112, read: (row: Listing) => ownerRemarkText(row) },
- { label: '租客纯币价格', width: 101, read: (row: Listing) => renterPureCoinPriceText(row) },
{ label: '租期', width: 70, read: (row: Listing) => rentalPeriodText(row) },
+ { label: '租客纯币价格', width: 101, read: (row: Listing) => renterPureCoinPriceText(row) },
{ label: '租金/押金', width: 106, read: (row: Listing) => rentAndDepositText(row) },
{ label: '比例', width: 42, read: (row: Listing) => formatRatio(row) },
{ label: '详情', width: 64, read: () => '详情' },
@@ -517,7 +519,7 @@ function renderTableScreenshotCanvas(rows: Listing[]) {
drawCellText(
ctx,
headerLines[columnIndex] ?? [column.label],
- x + cellPaddingX,
+ x + column.width / 2,
y + cellPaddingY + 12,
lineHeight
)
@@ -538,7 +540,7 @@ function renderTableScreenshotCanvas(rows: Listing[]) {
columnIndex === 0
? '700 13px Arial, "Microsoft YaHei", sans-serif'
: '13px Arial, "Microsoft YaHei", sans-serif'
- drawCellText(ctx, lines, x + cellPaddingX, y + cellPaddingY + 14, lineHeight)
+ drawCellText(ctx, lines, x + column.width / 2, y + cellPaddingY + 14, lineHeight)
x += column.width
}
drawLine(ctx, paddingX, y + rowHeight, paddingX + tableWidth, y + rowHeight)
@@ -569,12 +571,13 @@ function wrapCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWidth: n
function drawCellText(
ctx: CanvasRenderingContext2D,
lines: string[],
- x: number,
+ centerX: number,
y: number,
lineHeight: number
) {
+ ctx.textAlign = 'center'
lines.forEach((line, index) => {
- ctx.fillText(line, x, y + index * lineHeight)
+ ctx.fillText(line, centerX, y + index * lineHeight)
})
}
@@ -648,8 +651,12 @@ function zoneCompensationText(row: Listing) {
return `${zone}\n${compensation}`
}
-function regionLoginCoinText(row: Listing) {
- return [regionText(row), row.login_platform || '-', listingCoinM(row)].join('\n')
+function regionCoinText(row: Listing) {
+ return [regionText(row), listingCoinM(row)].join('\n')
+}
+
+function loginMethodText(row: Listing) {
+ return row.login_platform?.trim() || '-'
}
function insuranceSlotText(row: Listing) {
@@ -1070,15 +1077,22 @@ function formatQuantity(value: number) {