65 KiB
pkgs/ecmall 开发上下文
本文面向需要在 pkgs/ecmall 或其关联子模块上继续开发的工程师,说明这个聚合入口是什么、代码怎么组织、请求怎么流转、数据放在哪里、哪些实现是缺口,以及改动时的注意事项。
其他文档的分工:模块职责概览见 根 README,聚合入口与请求分流的实现见 系统架构,配置/构建/发布流程见 开发与运维,逐接口的请求字段见 API 接入总览 与 wiki/api/01-*.md ~ wiki/api/18-*.md。
本文所有结论均来自当前工作区源码。带
文件:行的引用表示已核对到具体位置;标注「推断」的表示由代码推导而未直接验证。
1. 定位与边界
pkgs/ecmall 是面向电商业务的聚合宿主(module 路径 bsm/full/pkgs/ecmall),监听 gRPC 12300 / HTTP 12301。它把电商交易、资金支付、平台运营和它们依赖的基础支撑服务装配进同一个进程,共享同一套监听、鉴权、grpc-gateway、动态 RPC 与配置装配实现。
注册的 14 个服务:
| 分组 | 服务 | 子模块 |
|---|---|---|
| 基础支撑 | initial、passport、sender | module/base/* |
| 平台与运营 | ads、cms、feedback、fts、logs、mgt | module/base/* |
| 电商与交易 | address、mall、market、order | module/ec/* |
| 资金与支付 | wallet | module/finance/wallet |
对外接口面:11 个模块注册 35 个 gRPC 服务(同时可用原生 gRPC、grpc-gateway 与动态 RPC),fts、logs、mgt 三个模块只有原生 REST 路由(/rest/fts、/rest/logs、/rest/mgt),不注册任何 gRPC 服务。专项配置节为 Fts、Mgt、Passport、Sender、Wallet;go.mod 中有 14 个业务模块的本地 replace。
未接入:module/base/cloud(唯一未注册的 base 模块,其路由与专项配置在 ecmall 中完全不存在),以及全部 module/social/*(feed、group、relation 未接入任何聚合入口)。
关键约束:pkgs/ecmall 只是注册表的取舍,不复制任何子模块业务逻辑。任何功能改动都应落在 module/ 下的对应模块里,pkgs/ecmall 只改注册表、配置结构或聚合自身的服务端代码。
2. 代码地图
pkgs/ecmall/
├── cmd/main/main.go 聚合进程入口(--workspace 参数、信号处理)
├── etc/default_dev.yaml 开发环境配置样例(端口、数据库、服务列表、专项配置)
├── go.mod / go.sum 14 个本地 replace + SDK
└── internal/
├── config/config.go 聚合配置结构与校验
├── config/config_test.go 配置测试(校验服务集合与必需配置节)
├── impl/impl.go 共享 DB / Redis / etcd / 内存缓存
├── server/ 聚合服务端(与业务无关的基础设施)
│ ├── server.go gRPC + HTTP 生命周期、gateway/REST 分流
│ ├── authorization.go JWT 鉴权(gRPC 拦截器 + HTTP 中间件)
│ ├── dynamic.go 动态 RPC(反射 + dynamicpb)
│ └── response.go 统一错误响应
└── service/ 注册表与依赖注入(每服务一个文件)
├── service.go 注册表 + Expose 主流程
├── service_test.go 注册表冒烟测试(gRPC 服务 + REST 路由)
├── ads.go cms.go feedback.go fts.go
├── initial.go logs.go mgt.go passport.go sender.go
└── address.go mall.go market.go order.go wallet.go
internal/server 与 internal/service 之外的部分都是模板化的;真正的业务代码全部在 module/ 里。
3. 启动与配置
3.1 启动顺序
pkgs/ecmall/cmd/main/main.go:
- 解析
--workspace(默认default),经utils.MustString校验 config.New(ServiceKey)→ 读配置、校验监听地址/密钥/有效期、回填共享配置impl.NewImpl()→ 创建进程级共享的内存缓存、Redis、数据库、etcd 客户端server.New(key, expire, anonymous)→ 建 gRPC server(挂鉴权拦截器 + reflection)与 Gin engineservice.Expose(srv)→ 遍历注册表,对启用的服务applyDependencies+ 注册 gRPC/gatewaysrv.Start(grpcAddr, httpAddr)→ 监听并阻塞SIGINT/SIGTERM→srv.Stop(ctx),先GracefulStop,10 秒超时后强制Stop,再关 HTTP(cmd/main/main.go:46-57)
3.2 配置解析规则
由 SDK conf.New 决定(D:\work\bsm-sdk\core\conf\new.go:25-67):
<BSM_Prefix>/etc/<小写 serviceKey>_<BSM_RuntimeMode>.yaml
- 文件不存在时回退到
<BSM_Prefix>/etc/workspace_<BSM_Workspace>_<Mode>.yaml(new.go:39-43) - YAML 内容会经
os.ExpandEnv,支持${VAR}替换(new.go:55) - YAML 中必须包含
Service:键,否则log.Fatalln(new.go:58-60) - 解析使用非严格模式,未知键被静默忽略(
new.go:63)
因此 --workspace=tenant01 + BSM_RuntimeMode=dev 读的是 pkgs/ecmall/etc/tenant01_dev.yaml。
3.3 环境变量
| 变量 | 默认值 | 作用 |
|---|---|---|
BSM_RuntimeMode |
dev(转小写) |
决定配置文件名后缀;prod 时 BSM_Prefix 默认 /usr/local/bsm |
BSM_Prefix |
dev: 进程工作目录;否则 /usr/local/bsm |
配置与证书根目录 |
BSM_Workspace |
default |
回退配置的文件名 |
BSM_SERVICES |
空 | 聚合专用,非空时覆盖 Services 列表 |
BSM_JwtSecretKey |
Cblocksmesh2022C |
被 config.New 用 Authorization.Key 覆盖 |
BSM_Licence |
空 | 许可文件路径 |
(D:\work\bsm-sdk\core\env\env.go:15-35;BSM_SERVICES 见 internal/config/config.go:109-124)
3.4 ecmall 的配置节
| 节 | 作用 |
|---|---|
Server.GRPC / Server.HTTP |
两段监听地址,相同会 panic |
Authorization |
JWT 密钥(必须 16/24/32 字节)、有效期秒数、匿名方法白名单 |
Databases |
全进程共用的数据库连接(Driver + Source) |
Cache |
共享 Redis DSN |
SecretKey |
被 service.Expose 用作 session 密钥(internal/service/service.go:29) |
Services |
启用哪些注册表服务;值里出现 all 表示全启用 |
Fts / Mgt |
fts 与 mgt 的专项配置(对象存储/本地上传、初始化 root 用户)。聚合启动时把共享的 Base/Databases/Rpc/APM/Etcd 回填进这两个结构(internal/config/config.go:92-97),不含 MicroService |
Passport / Sender / Wallet |
三个模块的专项配置,聚合启动时把共享的 Base/Databases/MicroService/Rpc/APM/Etcd 回填进这三个结构(internal/config/config.go:98-106) |
ads、cms、feedback、logs 没有专项配置节:它们的 service 包不暴露 Config 类型,聚合无法向它们注入任何模块级配置,只能注入共享依赖。Fts、Mgt 两个节在 etc/default_dev.yaml 中必须存在,否则 config_test.go 直接失败。
Services 与 BSM_SERVICES 的匹配是大小写不敏感的,BSM_SERVICES 优先(internal/config/config.go:109-124)。
3.5 ⚠️ 两个必须知道的坑
(1)聚合会把所有子模块摁到同一个数据库上。
各模块独立运行时的开发配置指向不同的库:
| 模块 | 独立 dev 配置的 dbname | 独立 dev 配置的 Redis |
|---|---|---|
| ads | rst_dev |
127.0.0.1:6379 |
| cms | ec_mall |
127.0.0.1:6379 |
| feedback | milu |
127.0.0.1:6379 |
| fts | bsm_dev |
127.0.0.1:6379/0 |
| initial | rst_dev |
127.0.0.1:6379 |
| logs | 达梦 DAMENG(Driver: dm) |
127.0.0.1:6379/0 |
| mgt | ec_dev(端口 19432) |
127.0.0.1:19379/0 |
| passport | ec_dev(端口 19432) |
127.0.0.1:19379 |
| sender | bsm_dev |
127.0.0.1:6379 |
| address / mall / market / order | ec_mall |
127.0.0.1:6379 |
| wallet | 模板值 "{ServiceKey}" |
127.0.0.1:6379 |
而 pkgs/ecmall/etc/default_dev.yaml 用 Postgres 的 dbname=bsm_dev。聚合模式下所有子模块共用这一个 *gorm.DB 和同一个 Redis DSN,与各自独立运行时的目标库、甚至驱动都不一致(logs 独立时走达梦,feedback 走 milu)。部署前必须确认目标库里这 14 个模块的表都在。
(2)聚合启动不会建表,也不会跑 seed。
- 聚合走 SDK
with.Databases(cfg, nil)(internal/impl/impl.go:38)。SDK 仅在options.IsAutoMigrate为真时执行迁移,而该默认值是false(D:\work\bsm-sdk\core\database\sql\postgresql.go:17、D:\work\bsm-sdk\core\database\new.go:43),且conf.DBConf只暴露Driver/Source两个键,无法从 YAML 打开(D:\work\bsm-sdk\core\conf\types.go:18-21)。 - 第一套(
database.AppendMigrate/database.MigrateTables登记):ads(ads_item.go:38、ads_pos.go:22)、cms(9 个模型各自init(),如cms_accessory.go:22)、feedback(3 个模型各自init())、fts(fts_record.go:40)、logs(log_data.go:24),以及initial、passport、sender、mall、wallet。由于上面那条默认值,这套登记在独立模式与聚合模式下都不会真正建表。 - 第二套(不设开关的
DBService.AutoMigrate(migrateTables...)):address、market、order,以及mgt(module/base/mgt/internal/models/impl.go:61,其后紧跟InitRootUserData())。它们只在模块自己的impl.NewImpl()/models.New里被调用,而聚合只调用applyDependencies、从不调用模块的impl.NewImpl()(见 §6)——所以聚合模式下同样不建表。 - 各模块的
InitData()(默认店铺/默认钱包/默认 CMS 根分类/mgt root 用户等 seed)都挂在独立入口的srv.Use(...)或models.New上,聚合路径不执行。passport与relation的这行甚至是注释掉的(module/base/passport/cmd/main/main.go:37、module/social/relation/cmd/main/main.go:37)。
结论:表结构必须在启动 ecmall 之前由独立模式启动一次、迁移脚本或 DBA 建好;mgt 的管理员账号也需要另行播种。
4. 请求链路与鉴权
4.1 四种入口,ecmall 全都有
| 入口 | 形态 | ecmall 是否可用 |
|---|---|---|
| 原生 gRPC | /{package}.{Service}/{Method} |
✅ 35 个服务 |
| grpc-gateway | POST /{package}.{Service}/{Method} |
✅ 同 35 个服务 |
| 动态 RPC | POST /rpc/{package}/{Service}/{Method} |
✅ 对全部已注册的 gRPC 服务可用 |
| 原生 REST(Gin) | /rest/{module}/... |
✅ 仅 fts、logs、mgt(约定入口) |
/rest/fts、/rest/logs、/rest/mgt 来自这三个模块的 internal/routers,由 internal/service/{fts,logs,mgt}.go 把聚合的 srv.HTTP 作为 Engine 传入后注册;其余 11 个模块都没有 internal/routers 目录,因此 ecmall 的原生 REST 面就等于这三个模块的路由集合(/rest/fts/ping|config|uploader、/rest/logs/ping|create|fetch|total、/rest/mgt/*)。
4.2 分流实现
internal/server/server.go:63-71:请求先交给 grpc-gateway,响应被 bufferedResponse 缓冲;仅当 gateway 返回 404 时才把同一请求交给 Gin。Gin 侧只注册了一条动态路由 POST /rpc/:module/:service/:method(server.go:61)。
监听参数:ReadHeaderTimeout=10s、IdleTimeout=120s、MaxHeaderBytes=1MB(server.go:72-78)。
4.3 动态 RPC 机制
internal/server/dynamic.go:
- 通过 gRPC 反射(
grpc_reflection_v1)按package.Service拉取FileDescriptorProto,用protodesc解析后缓存方法描述符(dynamic.go:115-176) - 用
dynamicpb构造请求/响应,JSON ↔ protobuf 走protojson(dynamic.go:94-112) - 只支持 unary,streaming 明确报错(
dynamic.go:79-82) - 请求体上限 4 MiB(
dynamic.go:30、:89-92) - 只透传
authorization、x-request-id和所有x-前缀头到下游 gRPC(dynamic.go:178-190) - 需要本进程开启 gRPC reflection,而
server.New无条件reflection.Register(server.go:33)
4.4 鉴权
internal/server/authorization.go:
- gRPC:unary 拦截器,非匿名方法必须带
authorizationmetadata(authorization.go:42-53) - HTTP:中间件校验
Authorization头(authorization.go:55-66) - Token 为 HS256 JWT,直接放在
Authorization头,不加Bearer(加前缀会被判为解析失败) - 校验项:签名算法必须 HS256、必须有
exp、必须有iat、iat不能在将来、now - iat不得超过配置的Expire(authorization.go:68-94) - 路径规范化:
/rpc/passport/Login/Pwd会被改写为/passport.Login/Pwd再去查白名单(authorization.go:105-112)——白名单只写网关形式即可同时覆盖动态 RPC grpc.reflection.*与grpc.health.*前缀始终匿名(authorization.go:98-100)
4.5 匿名白名单现状
pkgs/ecmall/etc/default_dev.yaml 当前列了:
passport.Login/Pwd、passport.Login/Code、passport.Login/Quickpassport.Register/Pwd、passport.Register/Codepassport.Forget/Verify、passport.Forget/Resetpassport.Verify/Request、passport.Verify/JumioCallbackmarket.Agency/Login、mall.Staff/Login/rest/fts/ping、/rest/fts/config、/rest/logs/ping/rest/mgt/ping、/rest/mgt/session、/rest/mgt/login、/rest/mgt/refresh、/rest/mgt/reset
REST 条目必须写成 path.Clean 后的真实路径(/rest/logs/ping),不会被改写成网关形式;/rpc/... 才会被规范化成 /{package}.{Service}/{Method}(见 §4.4)。
注意,以下几组接口在模块自己的配置/实现里是放开的,但 ecmall 的宿主鉴权不会自动跟随:
| 接口 | 模块自己的行为 | ecmall 中的行为 |
|---|---|---|
ads.Fetch/ByPos |
dev 配置里列为匿名(module/base/ads/etc/ads_dev.yaml:16) |
需要 JWT |
sender.Sms/Send、sender.Sms/Verify、sender.Mail/Send |
dev 配置里匿名 | 需要 JWT |
feedback.Method/Get、Delete、Remark、List |
逻辑内部不鉴权、不校验归属(§9.3) | 需要 JWT(宿主拦截),但通过后仍无归属校验 |
logs 的 POST /rest/logs/create、fetch、total |
模块内 JWT 中间件被注释 | ecmall 白名单未列这三条 → 需要 JWT(/rest/logs/ping 已列入白名单) |
如果希望保持独立运行时的行为,必须显式加入白名单。
4.6 错误响应
统一为 {code, message, details, timeseq},HTTP 状态码恒为 200,code 为 gRPC status code(internal/server/response.go:66-77)。sdkError 会保留 SDK 自定义业务码(response.go:18-24)。
5. 服务注册表(35 个 gRPC 服务 + 3 个 REST-only 模块)
| 模块 | gRPC 服务 | 通过 ExposeOptions.Config 传入配置 |
注册方式 |
|---|---|---|---|
| ads | Fetch(1) |
否 | gRPC + gateway |
| cms | Category(4)、Pages(6)、Post(19)、Site(5)、Tags(4) |
否 | gRPC + gateway |
| feedback | Method(6) |
否 | gRPC + gateway |
| fts | 无 | ✅ Fts |
仅原生 REST /rest/fts |
| initial | Check、Data |
否 | gRPC + gateway |
| logs | 无 | 否 | 仅原生 REST /rest/logs |
| mgt | 无 | ✅ Mgt |
仅原生 REST /rest/mgt |
| passport | Account、Forget、Login、Register、Verify |
✅ Passport |
gRPC + gateway |
| sender | Mail、Sms |
✅ Sender |
gRPC + gateway |
| address | Library |
否 | gRPC + gateway |
| mall | Ads、Category、Freight、Notice、Product、Staff、Store |
否 | gRPC + gateway |
| market | Agency、Data、Supply |
否 | gRPC + gateway |
| order | Cart、Coupon、Mgt、Summary |
否 | gRPC + gateway |
| wallet | Alipay、Basic、Payment、Wechat |
✅ Wallet |
gRPC + gateway |
fts、logs、mgt 三个模块的 ExposeOptions 只有 Dependencies + Engine(*gin.Engine),没有 GRPC/Gateway 字段,因此它们既不出现在原生 gRPC 面,也不出现在 gateway 与动态 RPC 面,只能通过 /rest/... 调用。
注册来源:模块的 service/expose.go(gateway / REST)与 internal/server/new.go(gRPC)。逐方法清单见 wiki/api/01-*.md ~ wiki/api/18-*.md;注意无需手写 google.api.http 注解,所有 gRPC 模块的网关路径都是 grpc-gateway 默认的 /{package}.{Service}/{Method}(全部 POST)。
改注册表的正确做法:在 internal/service/ 加/删一个 exposeXxx 文件,并在 internal/service/service.go:14-32 的 services 表里增删一行;同时同步 internal/config/config_test.go 的 ecmallServices 期望值与 etc/default_dev.yaml 的 Services 列表(该测试会强制三者一致)。internal/service/service_test.go 还会断言各模块的 gRPC/REST 面确实注册成功,漏接线会直接失败。
6. 依赖注入机制
每个模块的 service/expose.go 都长这样:
type ExposeOptions struct {
Dependencies
GRPC *grpc.Server // 仅 gRPC 模块
Gateway *gwRuntime.ServeMux // 仅 gRPC 模块
Engine *gin.Engine // 仅 fts / logs / mgt(原生 REST)
Config *Config // 仅 fts / mgt / passport / sender / wallet
}
func Expose(options ExposeOptions) error {
applyDependencies(options.Dependencies)
if options.Config != nil { config.Spec = *options.Config }
server.New(options.GRPC) // 仅 gRPC 模块
routers.Register("mgt", options.Engine) // 仅 REST 模块
// ... 或注册 grpc-gateway handler
}
applyDependencies 把聚合进程的共享对象写进模块自己的 internal/impl 包变量:
| 模块 | Redis | Etcd | DB | Cache |
|---|---|---|---|---|
| ads | ✅ | ✅ | ✅ | ⚠️ 写入 impl.MemorySerice,但全模块无读取点 |
| cms | ✅ | ✅ | ✅ | ⚠️ 写入 impl.MemoryService,但无读取点 |
| feedback | ✅ | ✅ | ✅ | ⚠️ 写入 impl.MemorySerice,但无读取点 |
| fts | ✅ | ✅ | ✅ | ⚠️ 写入 impl.MemorySerice,但无读取点 |
| initial | ✅ | ✅ | ✅ | ✅ → impl.MemorySerice |
| logs | ✅ | ✅ | ✅ | ❌ 完全忽略(连 impl 变量都不存在) |
| mgt | ✅ | ✅ | ✅ | ⚠️ 写入 impl.MemorySerice,但无读取点 |
| passport | ✅ | ✅ | ✅ | ✅ → impl.MemoryService |
| sender | ✅ | ✅ | ✅ | ✅ → impl.MemorySerice |
| address | ✅ | ✅ | ✅ | ❌ 接收但未使用 |
| mall | ✅ | ✅ | ✅ | ✅ → impl.MemorySerice |
| market | ✅ | ✅ | ✅ | ❌ 接收但未使用 |
| order | ✅ | ✅ | ✅ | ❌ 接收但未使用 |
| wallet | ✅ | ✅ | ✅ | ✅ → impl.MemorySerice |
即:Dependencies.Cache 在 address / market / order 中是死字段,在 logs 中根本没有落点;ads / cms / feedback / fts / mgt 虽然写入了 impl 变量,但模块内没有任何读取点——往这 9 个模块注入内存缓存都不会产生业务效果。另外多数模块把该字段拼成 MemorySerice(少一个 v),属于既有拼写,改名要连 internal/impl 一起动。
applyDependencies 只在值非 nil 时覆盖,所以模块的包变量在聚合模式下始终是聚合注入的对象;模块自己的 impl.NewImpl() 不会被调用(这也是 §3.5 中「不建表」的原因)。反过来说,聚合若漏注某一项(例如 fts/mgt 需要的 DB 或 Redis),模块不会报错,而是在首个请求上 panic——各模块的 applyDependencies 都是静默跳过 nil。
7. 数据与表
7.1 表清单(按归属模块)
| 模块 | 表 |
|---|---|
| ads | ads_item、ads_pos(后者空模型) |
| cms | cms_accessory、cms_category、cms_comment、cms_pages、cms_post、cms_relate_category、cms_relate_tags、cms_site、cms_tags |
| feedback | feedback_accessory、feedback_images、feedback_item |
| fts | fts_record |
| initial | initial_apps、initial_areas、initial_config、initial_country、initial_datas |
| logs | log_data(无 TableName(),按 GORM 默认名——推断) |
| mgt | mgt_users、mgt_roles、mgt_departments、mgt_applications(四者无 TableName(),用 GORM 复数默认名)、mgt_permission、mgt_link_user_dpt、mgt_link_user_role、mgt_link_user_pmn、mgt_link_user_app、mgt_link_role_pmn、mgt_link_role_app、mgt_link_dpt_role、mgt_link_dpt_pmn |
| passport | passport_account、passport_data、passport_notify、passport_provider(无 TableName())、passport_rights_expiry、passport_score、passport_statistics、passport_tags、passport_verify |
| sender | SenderTemplate(无 TableName(),按 SDK 的 SingularTable 推断为 sender_template——推断) |
| address | address_library |
| mall | mall_ads、mall_apply、mall_category、mall_freight、mall_freight_attr、mall_freight_deny、mall_notice、mall_product、mall_product_attr、mall_product_comment、mall_product_photos、mall_product_spec、mall_staff、mall_store、product_category、product_spec |
| market | market_agency、market_supply |
| order | order_cart、order_coupon、order_details、order_summary |
| wallet | wallet_apply_cash、wallet_bank、wallet_basic、wallet_payment、wallet_record、wallet_refund |
注意 mall 里有两个不带 mall_ 前缀的表:product_category、product_spec(mall_product_link_category.go:20、mall_product_link_spec.go:20)。
7.2 共享字段约定
跨模块关联不用数字外键,全部用字符串 identity(D:\work\bsm-sdk\core\types\db.go:20-76):
| 嵌入结构 | 字段 | 说明 |
|---|---|---|
Std_IDIdentity |
id、identity |
identity 为 varchar(36) 唯一索引,24 位 NanoID 或 36 位 ULID |
Std_IICUDS |
id、identity、created_at、updated_at、deleted_at、status |
最常用;status 默认 0、-1 禁用、1 正常 |
Std_Passport |
passport_id、passport_identity |
指向用户;跨模块引用用户就靠 passport_identity |
Std_Owner |
owner_id、owner_identity |
资源归属 |
Std_Status |
status int64 |
仅状态 |
业务侧还有一组约定字段:store_identity(店铺)、product_identity(商品)、address_identity(收货地址)、order_no(18 位订单号,module/ec/order/internal/logic/common/no.go:10)。改写任何一张表的这些列都会波及其它模块,属于破坏性变更。
7.3 跨模块表访问矩阵(重要)
各模块之间没有 Go 包依赖,但有直接跨模块读写表:
| 使用方 | 目标表 | 归属 | 位置 |
|---|---|---|---|
| cms | mall_staff |
mall | module/base/cms/internal/logic/post/add_comment.go:35 |
| mall | market_supply |
market | module/ec/mall/internal/logic/product/item_detail.go:31 |
| order | address_library |
address | summary/submit.go:68、summary/quick_create_by_product.go:79、mgt/order_create.go:101 |
| order | mall_product |
mall | summary/submit.go:82、:112,summary/quick_create_by_product.go:51、:53,mgt/order_create.go:57 |
| order | mall_product_spec |
mall | summary/submit.go:123、:204、:209,summary/quick_create_by_product.go:68,mgt/order_create.go:67、:141,mgt/order_approve.go:60 |
| order | mall_store |
mall | summary/quick_create_by_product.go:35 |
| order | product_spec |
mall | internal/logic/cart/fetch.go:44-46(原生 SQL JOIN) |
| wallet | order_summary |
order | module/finance/wallet/internal/models/query.go:104 |
| wallet | passport_account |
passport | module/finance/wallet/internal/models/query.go:24(硬编码 account="demo") |
order 里这些查询用 Table("...") + 本地投影结构体(models.Product、models.Spec、OrderAddress,见 module/ec/order/internal/models/query.go:3、:16、types.go:7),没有 gorm 模型、没有编译期约束:mall 改一个列名,order 只会在运行时报错。
反向也要记住:address、mall(含 mall_staff)、market、order、wallet 的表都有外部读者,不能随意改列。新加入的 6 个平台模块只新增了上表这一条跨模块读取(cms → mall_staff),彼此的 ads_*、cms_*、feedback_*、fts_record、log_data、mgt_* 表都与其它模块无关。
8. 核心业务链路
8.1 下单主链路(Summary.Submit)
module/ec/order/internal/logic/summary/submit.go:24-247:
- 鉴权:
service.ParseMetaCtx(ctx, &service.ParseOptions{RoleValue: "Mall_Admin"});如果请求带了内嵌Address,角色校验被置空(:26-33) - 读购物车:
order_cartwherepassport_identity = <当前用户>(:45) - 取收货地址:内嵌
Address优先;否则读address_librarywhereidentity = <AddressIdentity>(:55-76) - 按店铺分组:对每个购物车项读
mall_product取store_identity,以store_identity为 key 分桶(:79-95) - 每个店铺生成
summaryIdentity = utils.UUID()与orderNo = common.CreateOrderNo()(:103-104) - 店铺内逐件读
mall_product(title/cover_image/cost_price/supply_id/args/gas_types)与mall_product_spec(title/serial_number/price),累加金额(:112-135) - 组装
order_details与order_summary(写入地址快照、Std_Passport、StoreID/StoreIdentity、TransPrice/TotalPrice,Status = 1)(:138-180) - 单事务四步:建
order_summary→ 批量建order_details→ 逐条扣mall_product_spec.stock(带stock >= number守卫)→ 清空该用户order_cart(:187-228) - 返回第一个店铺的
order_no作为Identity,店铺 identity 列表拼在Message(:236-246)
已知缺陷:StoreId 只取第一个购物车项的 store_id(:91-93),却写给所有店铺分组的 OrderSummary.StoreID(:166)——多店铺下单时除首店外 StoreID 记录错误。LogisticsFee 恒为 0。该链路不触发任何支付调用,订单以 status=1 落库后由钱包侧推进。
8.2 支付链路
- 真实支付入口是
wallet.Payment.ByOrder/ByCharge(module/finance/wallet/internal/logic/payment/),按PayChannel(1 微信 / 2 支付宝 / 3 钱包余额)分发,内部复用wechat.go的MiniPaySign/AppPaySign/NativePaySign与alipay.go的TradeWapPay/TradeAppPay,并写wallet_payment。 wallet.Payment.ByOrder会通过models/query.go:104直接读order_summary.trans_price(status=1)核对金额。wallet.Alipay/*四个 RPC 与wallet.Wechat/AppPreOrder、NativePreOrder、Transfer全是 TODO,函数体只有参数校验 + 裸return(返回 nil reply、nil error)。也就是说渠道能力只能经Payment.*走通,走这几个 RPC 会静默失败。Wechat.WxCallback只做验签+解密后原样返回,注释// Todo: 业务逻辑,不落库、不改支付状态(module/finance/wallet/internal/logic/wechat/wx_callback.go:38)。- order 侧的
Summary.SimulatePay/SimulateShipments/SimulateReceiving是真实现,但交易号、金额、备注全是硬编码常量(module/ec/order/internal/logic/summary/simulate_pay.go:25),仅用于本地联调状态机,不能当支付/物流对接。
8.3 身份与验证码链路
- 用户身份只在
passport维护,其它模块通过passport_identity引用。 passport并不调用sender:全仓库没有module/base/sender被passport引用的记录,也没有sender.Sms/sender.Mail的调用点。验证码的生成与校验目前只存在于sender.Sms自身(Redis 键/SMS/Code/{phone},module/base/sender/internal/logic/sms/const.go:5)。- 与之对应,
passport的验证码类接口没有真正比对验证码:forget.Verify只判空 + 查账号状态(module/base/passport/internal/logic/forget/verify.go:15-26),login.Code、login.Quick同样只判空。mall.Staff/Login的验证码分支也不校验(module/ec/mall/internal/logic/staff/login.go:45-58)。 - ⚠️
passport的密码算法不统一:注册、改密、登录用 bcrypt,而forget.Reset用utils.Md5(module/base/passport/internal/logic/forget/reset.go:31)——重置后的密码无法通过登录校验。 wallet的钱包按用户懒创建;models.InitData()里有一段按account="demo"找默认用户并建钱包的 seed,但该函数只在独立入口经srv.Use(models.InitData)注册(module/finance/wallet/cmd/main/main.go:47),聚合模式不执行。
8.4 平台与运营链路(ads / cms / feedback / fts / logs / mgt)
这 6 个模块不参与下单与支付链路,也不通过 gRPC 互相调用(全仓库没有它们的 Dependencies/RPC 客户端;fts、logs、mgt 连 pb 都不存在)。它们的链路各自独立,只有下面这一处真正的跨模块耦合:
| 链路 | 走向 |
|---|---|
| 后台管理(mgt) | POST /rest/mgt/login → pub.Login 校验 mgt_users 密码并签发 JWT → /refresh 续期 → 其余 /user、/app、/role、/pmn、/dpt 接口经 JwtAuth + RequireAdmin 操作 13 张 mgt_* 表。聚合不播种 root 用户(§9.7),管理员必须另行建好 |
| 内容(cms) | 文章/页面/分类/标签读写 9 张 cms_* 表;发布评论时会按 JWT 身份去 mall_staff 取名(post/add_comment.go:35),取不到就直接返回 ErrDB,add_comment.go:40 还对结果做无保护断言 → 非 mall 员工身份评论会失败/panic |
| 反馈(feedback) | Add 写 feedback_item + 图片,Remark/Modify/Delete 改主表;没有工单流转、通知或与订单/商品的关联字段 |
| 文件(fts) | POST /rest/fts/uploader(模块自己加 JWT)→ 按 年-月/identity前2位 落本地 Local.UploadDir 或 MinIO → 写 fts_record → 返回 Local.Site 拼出的 URL;无秒传、无去重、无下载/删除接口 |
| 日志(logs) | POST /rest/logs/create 批量写 log_data,POST /fetch 与 /total 查询统计;模块内三条路由(含写入)都是匿名,但 ecmall 白名单只放行 /rest/logs/ping |
| 广告(ads) | 只读:ads.Fetch/ByPos 按 pos_key + status 查 ads_item 并解析成展示项 |
结论:把这 6 个模块接进 ecmall,得到的是同一个进程、同一套鉴权、同一个数据库,而不是一条新的业务闭环;它们之间的协作目前只存在于数据库层面。
9. 子模块速查
9.1 ads — 广告位内容分发(只读)
- 暴露:
Fetch(1),唯一方法ByPos;无Config;gRPC + gatewayPOST /ads.Fetch/ByPos - 表:
ads_item、ads_pos(ads_pos除迁移登记外没有任何查询,属空模型) - 逻辑:
internal/logic/fetch(按pos_key+status查启用广告并解析成文本/图片/视频条目) - 端口:独立 dev 配置 gRPC 12216 / gateway 12102,库
rst_dev;ads_prod.yaml与ads_test.yaml与 dev 逐字相同(prod 仍指向rst_dev+CHANGE_ME) - 缺口:
by_pos.go:21直接解引用impl.DBService,聚合未注入 DB 时首个请求 panic(推断)- 全量
Find无Limit/分页,广告量大时一次性返回 - 独立入口
cmd/main/main.go从不调用service.Expose,且internal/server/new.go:26-30从未给Server.Mux赋值 → 独立进程的 gateway 形同虚设(推断 404) impl.MemorySerice、impl.RedisService只写不读(README 宣称的 Redis 缓存不存在);pb/const.pb.go(约 3 千行base_ads_blocks)只被scripts/api-docgen空导入- 独立 gateway 端口 12102 与
initial的 gateway 端口相同(推断:两个模块独立部署时冲突)
- 测试:无(
test/rpc/rpc.go函数体整段被注释)
9.2 cms — 内容管理(文章/页面/分类/标签)
- 暴露:
Category(4)、Pages(6)、Post(19)、Site(5)、Tags(4),共 5 个服务 38 个方法;无Config;全部走 gRPC + gateway(POST /cms.<Service>/<Method>) - 表:9 张 ——
cms_post、cms_category、cms_tags、cms_comment、cms_pages、cms_site、cms_accessory、cms_relate_category、cms_relate_tags - 逻辑:
internal/logic/{post,pages,category,tags,site};鉴权靠每个 logic 方法内部调service.ParseMetaCtx(不在聚合层统一处理) - 端口:gRPC 12410 / gateway 12409;独立 dev 库
ec_mall - 缺口:
site子域整体空壳却已注册:site/create.go:20-27、modify.go:21-28、delete.go:27-32返回固定 OK 不写库;fetch.go:18-22、get.go:24-26返回零值 +nilerror- 未鉴权写入:
category/modify.go完全不调ParseMetaCtx - 跳过鉴权的读:
post/fetch.go、post/get_by_key.go、post/get_by_identity.go、post/search.go、pages/{fetch,get_by_key,get_by_identity}.go、category/fetch.go;post/comment_list.go:24的ParseMetaCtx被注释 - panic/错算:
post/add_comment.go:40未检查的类型断言;post/search.go:31的if pageSize < 50判断反向、:43先make(len(data))再 append(结果翻倍且前半为 nil);post/comment_list.go:18-19在钳制前取值 - 可刷量:
incr_post_like.go:33要求op_identity却从不使用(models/query.go:346TODO),重复点赞不去重 - 静默失败:
models/query.go:36忽略 Create 错误、:379忽略 Count 错误、:375用Find代替First(删除不存在的分类也报成功) InitData()无调用点 →_RootCategory永不创建;生产代码开着 GORM.Debug()(query.go:119等)与fmt.Println- 跨模块裸读
mall_staff(post/add_comment.go:35,见 §7.3)
- 测试:无(
test/*.http的路径已与生成的/cms.<Service>/<Method>脱节,实测会 404)
9.3 feedback — 用户反馈工单
- 暴露:
Method(6):List/Get/Add/Modify/Delete/Remark;无Config;gRPC + gatewayPOST /feedback.Method/<Method> - 表:
feedback_item、feedback_images、feedback_accessory - 逻辑:
internal/logic/method(单包) - 端口:dev 只有单端口
ListenOn: 0.0.0.0:12210;dev 库milu - 缺口:
Get/Delete/Remark完全不调ParseMetaCtx(get.go:19-42、delete.go:19-35、remark.go:19-41):能读任意工单的 email/phone、删任意工单、改任意备注状态;Modify只解析身份不校验归属(modify.go:24)- 假成功:
Delete/Remark/Modify不校验影响行数即返回 OK(delete.go:26-34、remark.go:32-40);Updates(struct)忽略零值 → 无法置status=0或清空备注 modify.go:36-38用新utils.UUID()覆盖identity后Updates(推断:unique 键被改写,记录失联);modify.go:71-75构造FeedbackAccessory未设Identity(第二条起唯一索引冲突)list.go:50过滤列写username,模型列名是user_name(feedback_item.go:15)→ 带该条件即 SQL 报错;list.go:38-45传agency就跳过passport_identity过滤(且agency无写入点,筛选恒假)- 假级联:
delete.go:25注释称会自动删图片/附件,但模型没有OnDelete,实际只软删主表 → 子表残留 - 独立 yaml 用的是旧键(
Name/ListenOn/Dsn),既无Service:也无Databases:→ 独立启动会在conf.New直接 fatal(推断)
- 测试:无(
test/rpc/rpc.go全注释)
9.4 fts — 文件上传与存储
- 暴露:没有 gRPC。
ExposeOptions只有Dependencies+Engine;Config就是internal/config.SrvConfig(含MinioOss、Local、FtsConfig) - REST:
GET /rest/fts/ping、GET /rest/fts/config(匿名)、POST /rest/fts/uploader(模块自带middleware.JwtAuth(true)) - 表:
fts_record(只写不读,handler.go:101);无跨模块表访问 - 逻辑:
internal/logic(上传编排)+internal/routers/uploader.go;存储走本地Local.UploadDir或 MinIO,本地 URL 由Local.Site+年-月/identity前2位拼成 - 端口:dev/test/prod 三份配置完全相同:单端口 16290,dev 库
bsm_dev;Local.Site指向外部静态站http://files.apinb.com - 缺口:
- 路径穿越面:
bucket直接取自c.PostForm(handler.go:27)未做任何校验,随后filepath.Join(UploadDir, bucket, ...)(provider.go:26,37)并进入返回 URL(provider.go:71)——../可逃出UploadDir - 上传校验薄弱:只按文件名后缀白名单且区分大小写(
handler.go:63-64,127-133),无 MIME/魔数校验;大小检查发生在 multipart 完全解析之后(handler.go:49-57) provider.go:115用identity[0:2],claims.Identity短于 2 字节即切片越界(被gin.Recovery兜成 500)- nil 解引用:
FtsConfig/Local/MinioOss三者都不校验(config.go:40只查Service、Cache),缺失时handler.go:49,57,128、provider.go:26,71,79panic record.Status硬编码 0、HandleCmd/HandleArgs永不写入、算好的Hash没有秒传/去重实现;文件已落盘后才因 chmod 失败返回错误(provider.go:63-67)- 死代码:
internal/logic/fetch.go整段注释、models/query.go:4-5空InitData()、internal/errors与internal/response(262 行)无导入方 Local.Site/UploadDir与匿名GET /rest/fts/config会把上传配置直接暴露给未认证调用方
- 路径穿越面:
- 测试:
internal/routers/register_test.go(当前失败,断言/rest/fts/v1/*而实现是/rest/fts/*;文件是 CRLF,也被gofmt -l判为未格式化);test/fts_test.go带//go:build integration且 URL 指向从未注册的/fts/Transfer/*
9.5 initial — 客户端初始化与基础字典
- 暴露:
Check(Hello/Config/Updates)、Data(Country/Areas/Datas);ExposeOptions无Config - 表:
initial_apps、initial_areas、initial_config、initial_country、initial_datas - 逻辑:
internal/logic/check(配置与版本)、internal/logic/data(字典),各带 Redis 缓存封装(键config、country、areas、datas) - 端口:gRPC 12101 / gateway 12102
- 缺口:
data/areas_cache.go:33-34按enabled = ?、show_town = ?过滤并按sort_order排序,而models.InitialAreas只有id/country_id/country_code/pid/deep/name/pinyin_prefix/pinyin/ext_id/ext_name十列,这三个列在模型里都不存在;check/config_cache.go:21把入参os直接拼进ORDER BY(注入面);check/updates.go:39用字符串不等判断版本,非语义化版本比较;注入的MemorySerice无人使用 - 测试:无
9.6 logs — 操作日志采集与查询
- 暴露:没有 gRPC,也没有
Config类型(模块级配置只在internal/config,聚合无法注入)。ExposeOptions只有Dependencies+Engine - REST:
GET /rest/logs/ping、POST /rest/logs/create|fetch|total,模块内全部注册为匿名(internal/routers/register.go:23的 JWT 中间件被注释,middleware/impl的 import 也已不存在)。注意聚合层只把/rest/logs/ping列入白名单,create/fetch/total在 ecmall 下仍需 JWT(见 §4.5) - 表:
log_data(模型LogData没有TableName(),按 GORM 默认名——推断);无跨模块表访问 - 逻辑:
internal/logic/log(批量写入 + 查询/统计)、internal/logic/hello(ping) - 端口:dev 配置
Service: oplogs、单端口 16289、Driver: dm(达梦,database=DAMENG);logs_test.yaml换成 postgres 的rst_dev,prod 与 dev 端口一致 - 缺口:
- 分页参数从不读取(
fetch.go:33-34,48-53)→ 永恒 page=1/size=50,客户端翻页静默失效 fetch.go:41-42条件键写op_ip却读request["ip"],缺ip时对 nil 断言 panic;fetch.go:36,39,45是无保护类型断言(JSON 数字解成 float64)→ panictotal.go:28用Group + First,只返回一个分组行而不是各级别统计;空表返回 ErrDB 而不是零值create.go:21的 IP 判定与其注释(create.go:19)相反:拦住127./localhost/10./172./192.,内网服务写不进来、公网 IP 反而通过create.go:28-40无批量上限,create/fetch/total均未鉴权;log_data.go:19-20的Hmac/Encry从不使用或校验cmd/cli/main.go是死代码(路径过期、HttpPost错误被忽略);fetch.go:36,39、total.go:24的tx.Where未回写(GORM 首次链式后原地改动,能用但脆弱——推断)
- 分页参数从不读取(
- 测试:无(
test/*.http仍指向/oplogs/v1/*)
9.7 mgt — 后台管理(RBAC 底座)
- 暴露:没有 gRPC;
ExposeOptions只有Dependencies+Engine;Config为Mgt节(InitRootUser、SecretKey) - REST:
/rest/mgt下共 63 条路由- 匿名 5 条:
GET /ping、GET /session、POST /login、POST /refresh、POST /reset(internal/routers/register.go:31-36) /user15 条:4 条本人可查(role/app/pmn/pmn_tree,仅需 JWT)+ 11 条 admin(create/del/detail/modify/fetch/list/set_role/del_role/set_pmn/modify_pmn/del_pmn)/app8、/role12、/pmn8、/dpt15,全部JWT + admin- 中间件:SDK
middleware.JwtAuth(true)+ 模块自己的internal/middleware/rbac.go(RequireAdmin:39、IsSuperAdmin:19、EnsureSelfOrAdmin:57)
- 匿名 5 条:
- 表:13 张 ——
mgt_users、mgt_roles、mgt_departments、mgt_applications(四者无TableName(),用 GORM 复数默认名)、mgt_permission,以及 8 张mgt_link_*(user_dpt、user_role、user_pmn、user_app、role_pmn、role_app、dpt_role、dpt_pmn);无跨模块表访问 - 端口:dev 单端口 18001、库
ec_dev(127.0.0.1:19432)、Redis19379/0;prod 库factor_prod - 缺口:
- 聚合不建表也不播种 root:
AutoMigrate与InitRootUserData()只在models.New里执行,而它挂在impl.NewImpl()←cmd/main上;聚合只调service.Expose(见 §3.5)。InitRootUser的注释/README 说「未配置默认 true」,实现是nil → false(internal/config/config.go:19-27),且mgt_prod.yaml/mgt_test.yaml都没有该键 → 生产默认关闭 - 匿名写端点:
POST /rest/mgt/reset无鉴权即可改密(仅靠 Redis 短信码,无频率限制);GET /rest/mgt/session匿名写 session(聚合的service.Expose已注册mysessionstore,internal/service/service.go:35,所以这条能跑通) user/list.go:29的原生 SQL 写的是mgt_application,真实表名是mgt_applications→ 该接口在 Postgres 上必然失败user/set_pmn.go:47,58在循环里赋值Appdta、却在循环外无条件Create→ 列表为空时插入(app_id=0, user_id=0)的关联行IsSuperAdmin用account == "root"或硬编码中文角色名「超级管理员」判定(rbac.go:14-15,24,31),DB 出错一律返回 false- nil 解引用:
impl.RedisService/impl.DBService未被注入时pub/forget.go:58、rbac.go:21panic;SecretKey字段没有 yaml tag,实际值来自BSM_JwtSecretKey(internal/config/config.go:39) - 死代码:
builtinManagementMenuTemplates()、ForgetPwdByPwd(空函数且未注册)、SaveToken均无调用点
- 聚合不建表也不播种 root:
- 测试:
internal/routers/register_test.go(本机通过)
9.8 passport — 用户账号与身份认证
- 暴露:
Account(Get/SetData/SetPassword/TagCreate/TagRemove/Statistics)、Forget(Verify/Reset)、Login(Pwd/Code/Quick)、Register(Pwd/Code)、Verify(Request/JumioCallback);有Config(Passport节:Token、Kyc、WeChatConf) - 表:9 张
passport_* - 逻辑:
account、common(JWT 生成)、forget、login、register、verify(Jumio KYC) - 端口:gRPC 12426 / gateway 12425;独立 dev 库为
ec_dev:19432、Redis19379 - 缺口:
verify.JumioCallback是占位:不验签、不落库,只打日志后返回成功(internal/logic/verify/jumio_callback.go:14-35)forget.Reset用 MD5,与登录/改密的 bcrypt 不一致(forget/reset.go:31)forget.Verify、login.Code、login.Quick不比对验证码account.Statistics的login_count统计的是账号行数(最多 1),不是登录次数models/cache.go整套缓存/验证码/登录尝试 API 与query.CreateAccount无任何调用方(死代码)WeChatConf只有 json tag 没有 yaml tag,config.Spec.WeChat在代码中未被使用
- 测试:无(仅
test/grpc/main.go手工客户端,硬编码api.apinb.com:10020)
9.9 sender — 短信与邮件
- 暴露:
Mail(Send)、Sms(Send/Verify);有Config(SMTPmap、SMSmap、Code) - 表:
SenderTemplate(无TableName()) - 逻辑:
internal/logic/sms(验证码生成/SetNX 落 Redis/校验、渠道分发)、internal/logic/mail(模板查询 +html/template+ SMTP) - 端口:gRPC 12208 / gateway 12207;独立 dev 库为
bsm_dev - 缺口:
- 腾讯云短信是空实现且静默成功:
TencentSender直接return nil, nil(internal/logic/sms/send.go:144-146),调用方随后json.Marshal(nil)得到"null"当成功返回 - 邮件只实现了
qq渠道,其它 provider 返回ErrNotProvider(internal/logic/mail/send.go:55-60);impl.NewSMTP直接返回 nil,Provider.Google/Provider.QQ是死字段 - 日发送量限制失效:
limitKey只Get从不写回(send.go:42-51),计数永不增长 - 黑名单只读不写:仓库内没有写
/SMS/BlackList/的代码;配置项Code.BlackListFilter、Code.GenerateCode、Code.CokeyKey均无读取点 sms/verify.go:34-35在比对失败分支删键,成功反而保留验证码config.Spec.Code无 nil 校验即解引用(send.go:49等)
- 腾讯云短信是空实现且静默成功:
- 测试:仅两个
//go:build integration的联网测试(test/grpc/mail_test.go、sms_test.go),默认go test不执行
9.10 address — 收货地址簿
- 暴露:
Library(Create/Modify/Get/Fetch/Delete);无Config - 表:
address_library - 逻辑:
internal/logic/library(唯一子包) - 端口:gRPC 12460 / gateway 12459;独立 dev 库为
ec_mall - 注意:
etc/address_dev.yaml的Service:写成了order(应为address),配置校验只要求非空所以没暴露问题 - 缺口:
fetch.go:38有模板遗留 TODO(功能已实现);无其它空实现 - 测试:无
9.11 mall — 店铺与商品
- 暴露:
Ads(5)、Category(4)、Freight(9)、Notice(4)、Product(21)、Staff(7)、Store(10);无Config - 表:16 张(见 §7.1)
- 逻辑:
internal/logic/{ads,category,freight,notice,product,staff,store}+internal/password(bcrypt)+internal/excode - 端口:gRPC 12420 / gateway 12419;独立 dev 库为
ec_mall - 缺口(多为「返回成功但不做事」):
freight下create、modify、delete、deny_region_create、deny_region_delete、deny_region_modify、deny_region_remove全部返回固定OK不写库;detail、fetch、deny_region_fetch是裸return(nil, nil)store/apply_join、store/mini_code空实现;store/licensing只查店铺后返回 OKproduct/item_detail_by_spec.go:16-18是// TODO: valid code+ 裸return- 孤儿函数:
freight/remove.go与freight/deny_region_remove.go有真实实现但没有被接线,server 映射到的是空实现版本 staff/login.go:45-58的验证码分支不校验验证码即签发 token;非 1/2 的LoginGenre返回ErrUnimplementeditem_fetch.go:28声明并用于 SQL 的idList从未 append;store/search.go:39用keyword过滤但列名是keywordsproduct/item_detail.go:31跨模块读market_supply
- 测试:仅
internal/password/password_test.go
9.12 market — 代理与供应商
- 暴露:
Agency(9)、Supply(5)、Data(5);无Config - 表:
market_agency、market_supply - 逻辑:
internal/logic/agency(省代账号全生命周期,含审核)、internal/logic/supply、internal/logic/data(看板) - 端口:gRPC 12424 / gateway 12423;独立 dev 库为
ec_mall - 缺口:
logic/data下 5 个方法(Overview/MemberFetch/MemberDetails/OrderFetch/OrderDetails)全部只有 TODO + 裸return,返回(nil, nil)agency/fetch.go:35、agency/pending.go:36写成tx.Where(...)未回写tx,identity 过滤很可能不生效agency/modify.go:53、supply/modify.go:46返回的Identity从未赋值,恒为空串agency/create.go:20-23、delete.go:17-21、supply/create.go:19-23的鉴权被注释掉;agency/create.go:50,52残留fmt.Println调试输出(含密码哈希)- prod/test 配置的匿名方法是
mall.ping.hello(与 dev 的market.ping.hello不一致)
- 测试:仅
internal/password/password_test.go
9.13 order — 购物车与订单
- 暴露:
Cart(4)、Coupon(1)、Mgt(7)、Summary(10);无Config - 表:
order_cart、order_coupon、order_details、order_summary - 逻辑:
internal/logic/{cart,common,coupon,mgt,summary} - 端口:gRPC 12442 / gateway 12441;独立 dev 库为
ec_mall - 缺口:
mgt/order_modify.go:25-33是假成功:只有 TODO 注释,直接返回Code:0, Message:"OK",不写库- 下单主链路缺陷见 §8.1(多店铺
StoreID错误) summary/quick_create_by_product.go大量对map[string]any的无保护类型断言(:43、:62、:102、:104、:112-117…),NULL 或字段缺失会 panic;配送员查询整段被注释cart/fetch.go:40-48原生 SQL 出错时静默丢数据(只在err == nil时追加结果,不记录也不返回)coupon/by_status.go:52把Intro赋给Amount;入参Status未被使用- 死代码:
models/query.go:22 QuicklyCreateOrder、order_summary.go:70 GetSummaryCnt、:83 GetSummaryList(后者引用了模型中不存在的列buyer_identity/merchant_identity/pay_status等) Simulate*见 §8.2
- 测试:
test/rpc/下 4 个联网集成测试(cart_test.go、coupon_test.go、mgt_test.go、summary_test.go)都带//go:build integration,默认go test不编译不执行;同目录bisic_test.go没有 build tag,但也不含任何Test*函数(只定义GetCoon()辅助函数,内硬编码127.0.0.1:12442与BSM_ORDER_TOKEN),所以默认go test ./...对这个包只是编译通过、不跑任何用例
9.14 wallet — 钱包与支付
- 暴露:
Alipay(4)、Basic(8)、Payment(6)、Wechat(5);有Config(WeChat、Alipay、Wallet、QrCodeSavePath) - 表:6 张
wallet_* - 逻辑:
internal/logic/basic(钱包、支付密码、银行卡、提现)、internal/logic/payment(渠道编排)、internal/logic/wechat、internal/logic/alipay - 端口:gRPC 12238 / gateway 12239
- 缺口:
Alipay.WapPay/PagePay/AppPay/Transfer与Wechat.AppPreOrder/NativePreOrder/Transfer全是 TODO(裸return);只有Wechat.JsapiPreOrder真正实现Wechat.WxCallback不落库不改状态jsapi_pre_order.go:22、wx_callback.go:26丢弃NewWechat()的错误,失败时会 nil 解引用wechat.go:48把WeChat.PrivateKey配置值当文件路径读取config.Spec.Alipay/Wallet在etc/wallet_dev.yaml中根本没有这两个节,指针为 nil,相关分支依赖调用时机wallet_test.yaml/wallet_prod.yaml用的是旧键名(Name/ListenOn/Dsn),与当前SrvConfig不匹配WalletRefund只有模型定义无任何读写;ChargeWallet无调用方- 跨模块读
order_summary、passport_account(硬编码account="demo",见 §7.3)
- 测试:无
10. 缺口与风险汇总(按优先级)
P0 — 影响正确性/安全
| 问题 | 位置 |
|---|---|
forget.Reset 用 MD5,与登录 bcrypt 不一致,重置后无法登录 |
module/base/passport/internal/logic/forget/reset.go:31 |
| 验证码接口不比对验证码(passport 三处 + mall staff 登录) | passport/internal/logic/forget/verify.go:15-26、login/code.go、login/quick.go、mall/internal/logic/staff/login.go:45-58 |
JumioCallback 不验签不落库即返回成功 |
module/base/passport/internal/logic/verify/jumio_callback.go:14-35 |
WxCallback 不落库不改支付状态 |
module/finance/wallet/internal/logic/wechat/wx_callback.go:38 |
| 聚合启动不建表不 seed(部署必踩) | §3.5 |
| 聚合把所有模块摁到同一个库,与各自独立 dev 库不同 | §3.5 |
market agency/create、delete、supply/create 鉴权被注释 |
module/ec/market/internal/logic/agency/create.go:20-23 等 |
initial config_cache 把入参拼进 ORDER BY(注入面) |
module/base/initial/internal/logic/check/config_cache.go:21 |
feedback Get/Delete/Remark 无鉴权且无归属校验(任意读 email/phone、删单、改备注) |
module/base/feedback/internal/logic/method/{get,delete,remark}.go |
mgt POST /rest/mgt/reset 匿名即可改密(仅靠短信码,无频率限制) |
module/base/mgt/internal/routers/register.go:36 |
logs create/fetch/total 全部匿名,create 还无批量上限 |
module/base/logs/internal/routers/register.go:23-27、internal/logic/log/create.go:28-40 |
| logs 写入接口的内网 IP 拦截与注释相反:内网写不进、公网放行 | module/base/logs/internal/logic/log/create.go:19-21 |
fts 上传的 bucket 直接取自表单且不校验 → ../ 可逃出 UploadDir |
module/base/fts/internal/logic/provider.go:26,37(来源 internal/routers/handler.go:27) |
cms Category.Modify 无鉴权写入;多处读接口跳过 ParseMetaCtx |
module/base/cms/internal/logic/category/modify.go、post/{fetch,search}.go 等 |
mgt InitRootUser 注释与实现相反(nil → false),prod 配置缺该键 → 管理员不播种 |
module/base/mgt/internal/config/config.go:19-27 |
P1 — 功能缺失但「看起来成功」
| 问题 | 位置 |
|---|---|
mall freight 7 个写操作返回固定 OK 不写库;3 个查询裸 return |
module/ec/mall/internal/logic/freight/* |
mall store/apply_join、mini_code 空实现 |
module/ec/mall/internal/logic/store/ |
mall product/item_detail_by_spec 空实现;实现好的两个 delete 未接线 |
module/ec/mall/internal/logic/product/item_detail_by_spec.go:16-18、freight/remove.go、freight/deny_region_remove.go |
market logic/data 5 个方法全空 |
module/ec/market/internal/logic/data/* |
order mgt/order_modify 假成功 |
module/ec/order/internal/logic/mgt/order_modify.go:25-33 |
| wallet Alipay 全部 4 个 + Wechat 3 个 RPC 为 TODO | module/finance/wallet/internal/logic/{alipay,wechat}/ |
| sender 腾讯云短信空实现且静默成功;邮件仅 qq | module/base/sender/internal/logic/sms/send.go:144-146、mail/send.go:55-60 |
cms site 5 个方法空壳(3 个假成功 + 2 个返回零值) |
module/base/cms/internal/logic/site/*.go |
feedback Delete/Remark/Modify 不校验影响行数即返回 OK;Updates(struct) 忽略零值 |
module/base/feedback/internal/logic/method/{delete,remark,modify}.go |
mgt user/list 原生 SQL 表名写成 mgt_application(真实 mgt_applications)→ 接口必然失败 |
module/base/mgt/internal/logic/user/list.go:29 |
mgt user/set_pmn 列表为空时仍插入 (app_id=0,user_id=0) 关联行 |
module/base/mgt/internal/logic/user/set_pmn.go:47,58 |
ads AdsPos 空模型;ByPos 全量 Find 无分页 |
module/base/ads/internal/logic/fetch/by_pos.go:21 |
fts record.Status 恒 0、HandleCmd/Args 永不写入、Hash 没有秒传/去重 |
module/base/fts/internal/logic/handler.go:70,85 |
P2 — 静默失效/数据不一致
| 问题 | 位置 |
|---|---|
| sender 日发送量限制永不生效;黑名单只读不写;验证码失败时才删键 | module/base/sender/internal/logic/sms/send.go:42-51、verify.go:34-35 |
order 多店铺下单 StoreID 记录错误 |
module/ec/order/internal/logic/summary/submit.go:91-93、:166 |
market agency/fetch、pending 的 identity 过滤未生效 |
module/ec/market/internal/logic/agency/fetch.go:35、pending.go:36 |
market modify 返回的 Identity 恒为空 |
module/ec/market/internal/logic/agency/modify.go:53、supply/modify.go:46 |
order coupon/by_status 把 Intro 当 Amount |
module/ec/order/internal/logic/coupon/by_status.go:52 |
mall item_fetch 的 idList 从未赋值;store/search 列名不符 |
module/ec/mall/internal/logic/product/item_fetch.go:28、store/search.go:39 |
| initial 地区缓存查询不存在的列 | module/base/initial/internal/logic/data/areas_cache.go:33 |
Dependencies.Cache 在 address/market/order 无效、在 logs 缺失、在 ads/cms/feedback/fts/mgt 无读取点 |
module/{base,ec}/*/service/dependencies.go |
address_dev.yaml 的 Service 写成 order |
module/ec/address/etc/address_dev.yaml:1 |
order quick_create_by_product 无保护类型断言易 panic |
module/ec/order/internal/logic/summary/quick_create_by_product.go:43,62,102,104 |
| logs 分页参数从不读取(恒 page=1/size=50) | module/base/logs/internal/logic/log/fetch.go:33-34,48-53 |
logs op_ip 条件读 request["ip"],且多处无保护类型断言 → panic |
module/base/logs/internal/logic/log/fetch.go:36,39,41-45 |
logs total 用 Group + First,只返回一个分组行(空表返回 ErrDB) |
module/base/logs/internal/logic/log/total.go:28 |
feedback list 过滤列写 username(列名是 user_name);agency 筛选恒假且可越权列表 |
module/base/feedback/internal/logic/method/list.go:38-50 |
feedback Modify 覆盖 identity;FeedbackAccessory 未设 Identity |
module/base/feedback/internal/logic/method/modify.go:36-38,71-75 |
cms post/search 分页判断反向且结果翻倍;comment_list 钳制失效 |
module/base/cms/internal/logic/post/search.go:31,43、comment_list.go:18-19 |
cms 点赞可刷量(op_identity 要求却从不使用) |
module/base/cms/internal/logic/post/incr_post_like.go:33 |
ads DB 未注入即 panic;fts identity[0:2] 越界、Local/MinioOss/FtsConfig 不校验 |
module/base/ads/internal/logic/fetch/by_pos.go:21、module/base/fts/internal/logic/provider.go:115 |
mgt IsSuperAdmin 硬编码 root 与中文角色名「超级管理员」 |
module/base/mgt/internal/middleware/rbac.go:14-15,24,31 |
P3 — 死代码/噪音(不影响运行,但改动时别被误导)
- passport
models/cache.go整套缓存 API、query.CreateAccount无调用方 - wallet
WalletRefund无读写、ChargeWallet无调用方 - order
QuicklyCreateOrder、GetSummaryCnt、GetSummaryList无调用方 - mall
InitData()(默认店铺 + root 员工 seed)无调用点 - 各模块
cmd/cli/main.go多为 Hello World;passport、sender、initial的 README 描述的 Makefile/Dockerfile 在仓库中不存在 config.Spec.Code、Code.BlackListFilter、Code.GenerateCode、Code.CokeyKey无读取点- 大量
// TODO: add your logic code & delete this line.只是模板残留,功能已实现(例如address/internal/logic/library/fetch.go:38) - ads
pb/const.pb.go(约 3 千行base_ads_blocks)只被scripts/api-docgen空导入;ads_pos除迁移登记外无任何查询 - cms
InitData()无调用点(_RootCategory永不创建);internal/models/query.go:18-20的表名常量值写成cms_relate - feedback / logs / ads 的
cmd/cli/main.go与test/rpc/rpc.go是 Hello World 或整段注释 - fts
internal/errors、internal/response(262 行)无导入方,internal/logic/fetch.go整段注释,models/query.go:4-5是空InitData() - mgt
builtinManagementMenuTemplates()、ForgetPwdByPwd、SaveToken、models/query.go均无调用点 - ads / cms / feedback / fts / logs / mgt 的 README 描述的 Makefile、Dockerfile、swagger、
/health、/metrics在仓库中都不存在
11. 开发指引
11.1 本地运行
# 聚合(14 个服务全开)
cd pkgs/ecmall && go run ./cmd/main --workspace=default
# 只跑其中几个服务(REST-only 的 fts/logs/mgt 同样用这里的名字)
BSM_SERVICES=passport,address,mall,order go run ./cmd/main --workspace=default
BSM_SERVICES=mgt,logs go run ./cmd/main --workspace=default
# 单个子模块独立运行(会走该模块自己的库与端口)
cd module/ec/order && go run ./cmd/main
前置条件:D:\work\bsm-sdk\core 必须存在(go.work 与各 go.mod 都按相对路径 replace 到它),且目标数据库/Redis/etcd 可达。config.New 与 impl.NewImpl 在依赖缺失时会 panic,不是降级启动。
11.2 验证
go work sync
./scripts/verify-workspace.ps1 # Windows:gofmt + 每模块 go vet + go test
./scripts/verify-workspace.sh # Linux/macOS
git diff --check
已知会使验证脚本失败的历史问题:module/base/fts/internal/routers/register_test.go 在工作区是 CRLF,会被 gofmt 判为未格式化;同目录的 TestRoutesUseRESTModulePrefix 断言 /rest/fts/v1/* 而代码注册的是 /rest/fts/*(既有失败,与 ecmall 无关)。
11.3 增删一个服务
pkgs/ecmall/internal/service/增加/删除对应的xxx.go(照抄现有文件的ExposeOptions形态:gRPC 型传GRPC+Gateway,REST 型传Engine: srv.HTTP;注意该模块是否需要Config)- 同步
internal/service/service.go:14-32的services表 - 同步
internal/config/:需要Config的模块要加结构体字段 +assignSharedConfig分支(注意 fts / mgt 的Config不含MicroService) - 同步
internal/config/config_test.go的ecmallServices与必需配置节断言 - 同步
etc/default_dev.yaml的Services列表(以及新配置节、必要的匿名白名单) - 同步
internal/service/service_test.go的前缀/路由期望值 go.mod加/删replace+require,然后GOWORK=off go mod tidygo build ./... && go test ./...
internal/config/config_test.go 会强制「YAML 服务列表 == 注册表期望值」,internal/service/service_test.go 会强制「注册表 → 真实 gRPC/REST 面」,漏改会直接测试失败。
11.4 新增一个 RPC
标准路径:改 module/<域>/<模块>/proto/*.proto → 用 scripts/generate-protobuf.sh 重新生成 pb/ → 在 internal/server/<svc>_server.go 加方法(委托到 internal/logic/<子包>)→ 在 internal/logic/<子包>/ 加实现。网关路径会由生成的 *.pb.gw.go 自动出现为 POST /{package}.{Service}/{Method},动态 RPC 路径自动可用,无需改 pkgs/ecmall。
11.5 新增配置段 / 匿名白名单
- 专项配置:加在模块
internal/config的结构体(要有 yaml tag),聚合侧在SrvConfig加同名字段并在assignSharedConfig回填共享项,然后写进pkgs/ecmall/etc/default_dev.yaml;fts / mgt 只回填Base/Databases/Rpc/APM/Etcd - 匿名白名单:写在
Authorization.Anonymous。gRPC 方法用网关形式/{package}.{Service}/{Method}(动态 RPC 的/rpc/...会被自动规范化后匹配);REST 路由必须写真实路径(如/rest/logs/ping),没有等价的网关写法 - 新增 REST-only 模块时,
ExposeOptions.Engine必须传聚合的srv.HTTP,否则路由不会挂到 ecmall 上
11.6 不要做的事
- 不要在
pkgs/ecmall里写业务逻辑——它是注册表 + 基础设施,业务改动属于module/ - 不要新增跨模块的表直读——现有耦合(§7.3)是历史包袱;新交互应走 gRPC
- 不要改
identity/store_identity/product_identity/passport_identity/order_no这些共享列的语义或命名,它们被多个模块以裸表名依赖 - 不要假设表会自动建——见 §3.5
- 不要假设
Cache注入生效(address/market/order/logs 以及所有只写不读的模块) - 不要以为
/rest/*默认匿名——聚合的 HTTP 中间件覆盖全部路径,只有白名单里的真实路径才跳过鉴权(§4.5) - 不要把 TODO 占位当成已实现能力——本仓库有大量「返回成功但什么都没做」的接口(§10)
12. 相关文档
- 根 README — 全部模块职责与实现边界
- 系统架构 — 两个聚合入口的注册表差异、请求分流、依赖注入
- 开发与运维 — 配置路径规则、验证/构建脚本、发布检查
- API 接入总览 — gRPC / 动态 HTTP / gateway / 鉴权总览
- 模块级 API(gRPC):
../../wiki/api/01-ads.md、03-cms.md、04-feedback.md、05-initial.md、06-passport.md、07-sender.md、08-address.md、09-mall.md、10-market.md、11-order.md、12-wallet.md - 模块级 API(原生 REST):
../../wiki/api/16-fts-rest.md、17-logs-rest.md、18-mgt-rest.md