From bc96295ed6bbf08b8a591fbed87e231af50d0580 Mon Sep 17 00:00:00 2001 From: yml Date: Fri, 13 Nov 2026 13:49:14 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + backend/internal/modules/paymentconfig/dto.go | 5 +- .../modules/paymentconfig/mutation.go | 12 +- .../modules/paymentconfig/presenter.go | 45 +- .../internal/modules/paymentconfig/query.go | 129 +- .../modules/paymentconfig/repository_test.go | 49 +- docs/双运营主体多租户改造分析.md | 1910 +++++++++++++++++ .../src/features/admin/api/paymentConfig.ts | 5 +- .../admin/views/AdminListingsView.vue | 52 +- .../admin/views/AdminPaymentConfigsView.vue | 53 +- frontend/src/styles/admin.css | 3 + 11 files changed, 2036 insertions(+), 228 deletions(-) create mode 100644 docs/双运营主体多租户改造分析.md 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) { + + + + + + - - - diff --git a/frontend/src/features/admin/views/AdminPaymentConfigsView.vue b/frontend/src/features/admin/views/AdminPaymentConfigsView.vue index cf2832bb..086a5f35 100644 --- a/frontend/src/features/admin/views/AdminPaymentConfigsView.vue +++ b/frontend/src/features/admin/views/AdminPaymentConfigsView.vue @@ -1,6 +1,6 @@ @@ -413,7 +403,7 @@ function deleteDisabledReason(row: PaymentConfig) {
备用 - - - - - -