《互联网产品的服务端架构·交易、增长与平台化》2.3 订单查询与用户可见性

从“列表里能看到订单、点进详情却 403”的故障切入,说明订单查询的可见性与字段裁剪:本人、管理员、陌生人三种视角各能看什么,列表与详情为什么必须共用同一个 owner 判定。给出 Visible 与 Mask 的 Go 实现与测试,并复盘两个接口各写一套鉴权导致的客诉与数据泄露风险。

列表里能看到的订单,点进去却 403

「拾集」的用户中心有两个接口:订单列表和订单详情。列表按用户 ID 过滤,只返回当前登录用户的订单;详情按订单 ID 查询,再判断“这张订单是不是我的”。上线后,客服反馈了一个奇怪的现象:部分用户在列表里能看到订单,点进详情却收到 403。

排查后发现,两个接口用了不同的 owner 判定。列表用的是 order.UserID == session.UserID;详情用的是 order.UserID == path.UserID,而 path.UserID 是前端从本地缓存里带上的,用户切换过账号或清理过缓存后,这个值可能是旧的。列表用会话,详情用路径参数,两边一旦不一致,就会出现“看得见、点不开”。

还有一个更隐蔽的版本:后台客服账号能在列表里看到用户订单,因为列表对管理员放行;详情接口却只判断 order.UserID == viewer.UserID,忘了管理员分支,于是客服点开任何订单都是 403。客服只能靠列表里的摘要处理问题,看不到收货地址和明细,工单处理时间翻倍。

这两个故障的共同点,是把“能不能看”和“看什么”分散在多个接口里各写一遍。列表、详情、导出、消息推送、客服后台,每多一个入口就多一套判断,迟早会漂移。

用户视图与后台视图

订单查询要先回答两个问题:谁能看,以及看完之后展示哪些字段。把这两个问题分开,才能既保证安全,又让不同角色看到合适的信息:

字段本人管理员 / 客服陌生人
订单号、状态、下单时间可见可见拒绝访问
商品 SKU、数量可见可见拒绝访问
单价、优惠、应付总额可见金额置零拒绝访问
收货地址、手机号可见按工单权限单独申请拒绝访问
支付流水号可见可见拒绝访问

这张表是案例假设值,真实系统要按隐私政策和工单权限调整。但结构是通用的:可见性判断只做一次,字段裁剪按角色做,列表和详情共用同一套规则。

“管理员能看到”不等于“管理员能看到所有字段”。客服处理物流问题需要地址,处理退款需要金额,但日常查单不应该默认把所有敏感字段铺在页面上。字段裁剪应该是白名单:先决定这个角色能看哪些字段,再组装返回,而不是查出完整对象后随手删几个字段。

列表和详情的口径必须一致。列表返回的每一条,点进去都应该能打开;详情能打开的,列表里也应该能查到。这不是前端体验问题,而是后端鉴权一致性问题。只要两个接口各写一套判断,就一定会出现“列表有、详情无”或反过来“详情有、列表无”的情况。

失败场景:两套 owner 判定

「拾集」的订单列表和详情分别由两个团队维护。列表接口的鉴权逻辑是:

if order.UserID == session.UserID || session.IsAdmin { allow }

详情接口的鉴权逻辑是:

if order.UserID == path.UserID { allow }

上线两周后,客服团队反馈:管理员账号在订单列表里能看到所有订单,但点开详情全部 403。与此同时,C 端有 37 位用户反馈“列表里有订单,点进去提示无权查看”,原因是他们最近切换过账号,本地缓存的 path.UserID 还是旧账号。

这组数字是案例假设值,但它揭示了两类真实风险:

  • 功能不可用:管理员和切换账号的用户被挡在详情外,客服效率下降,用户以为订单丢了。
  • 鉴权绕过:如果详情改成信任 path.UserID,攻击者只要把路径里的用户 ID 改成别人的,就能越权查看订单。列表用会话、详情用路径,两个口径里总有一个是错的。

修复不是把详情改成和列表一样的 if,而是抽出一个所有入口共用的函数:

  1. Visible(o Order, v Viewer) error 只回答“这个查看者能不能访问这张订单”,本人或管理员通过,其他身份返回错误。
  2. Mask(o Order, v Viewer) (Order, error) 在通过 Visible 之后,按角色裁剪字段:本人看完整订单,非本人看结构但金额置零。
  3. 列表、详情、导出、客服后台全部调用这两个函数,禁止各自写 owner 判断。
  4. 测试覆盖本人、管理员、陌生人、匿名四种身份,以及“裁剪后不修改入参”。

Mask 必须返回副本。如果它直接在入参上把金额改成 0,调用方后续再用这张订单去计算退款或对账,就会拿到被污染的数据。切片也要复制,否则修改返回值的明细会影响原对象。

Go 实现:Visible 与 Mask

下面的代码来自本书配套的 webapp-lab。Visible 把“匿名、本人、管理员、陌生人”收敛成一个判断;Mask 先调用 Visible,再复制订单并按角色裁剪金额。两个函数都不依赖数据库和 HTTP,方便所有查询入口复用。

// file: internal/order/visibility.go
// tier: L1
// verify: go test ./internal/order/

package order

import "fmt"

// Viewer 描述一次查询的发起者。Admin 表示后台身份,可以查看任意订单,
// 但金额明细仍会被 Mask 裁剪。
type Viewer struct {
	UserID string
	Admin  bool
}

// Visible 判断查看者能否访问这张订单。
func Visible(o Order, v Viewer) error {
	if v.UserID == "" && !v.Admin {
		return fmt.Errorf("order: viewer must have a user id or admin flag")
	}
	if v.Admin || (o.UserID != "" && v.UserID == o.UserID) {
		return nil
	}
	return fmt.Errorf("order: user %q is not allowed to view order %q", v.UserID, o.ID)
}

// Mask 返回一个适合展示给 v 的订单副本。
//
// 本人看到完整订单;管理员和通过 Visible 检查的其他身份看到同样的
// 订单编号、用户、状态和商品结构,但每行金额被置零。函数不修改入参,
// 也不会让返回值与入参共享底层切片。
func Mask(o Order, v Viewer) (Order, error) {
	if err := Visible(o, v); err != nil {
		return Order{}, err
	}
	out := Order{
		ID:     o.ID,
		UserID: o.UserID,
		State:  o.State,
	}
	if len(o.Items) > 0 {
		out.Items = make([]Item, len(o.Items))
		copy(out.Items, o.Items)
	}
	if o.UserID == v.UserID {
		return out, nil
	}
	for i := range out.Items {
		out.Items[i].PriceCents = 0
	}
	return out, nil
}

Visible 里有一条容易被忽略的规则:匿名查看者即使订单的 UserID 也是空字符串,也不能通过。空用户 ID 的订单通常是数据异常或系统单,不能因为“两个空值相等”就放行。管理员分支放在最前面,是因为后台身份不依赖订单归属。

Mask 先做完整复制,再对非本人把 PriceCents 置零。复制发生在裁剪之前,所以本人拿到的也是一个独立副本;调用方修改返回值不会影响原订单,也不会影响同一请求里后续的对账逻辑。

测试:本人、管理员、陌生人

测试覆盖四种身份和三个行为:能否访问、金额是否裁剪、入参是否被修改。最后一组尤其重要,因为 Mask 最容易写成的错误实现就是“直接在原对象上改”。

// file: internal/order/visibility_test.go
// tier: L1
// verify: go test ./internal/order/

package order

import (
	"strings"
	"testing"
)

func sampleOrder() Order {
	return Order{
		ID:     "o-1",
		UserID: "u-1",
		Items: []Item{
			{SKU: "a", PriceCents: 1999, Qty: 2},
			{SKU: "b", PriceCents: 500, Qty: 1},
		},
		State: StatePaid,
	}
}

func TestVisibleAllowsOwnerAndAdmin(t *testing.T) {
	o := sampleOrder()
	if err := Visible(o, Viewer{UserID: "u-1"}); err != nil {
		t.Fatalf("owner rejected: %v", err)
	}
	if err := Visible(o, Viewer{UserID: "ops-9", Admin: true}); err != nil {
		t.Fatalf("admin rejected: %v", err)
	}
}

func TestVisibleRejectsStranger(t *testing.T) {
	err := Visible(sampleOrder(), Viewer{UserID: "u-2"})
	if err == nil {
		t.Fatal("want error for stranger")
	}
	if !strings.Contains(err.Error(), "not allowed") {
		t.Fatalf("error %q does not mention authorization", err)
	}
}

func TestVisibleRejectsAnonymous(t *testing.T) {
	if err := Visible(sampleOrder(), Viewer{}); err == nil {
		t.Fatal("want error for anonymous viewer")
	}
}

func TestMaskKeepsOwnerViewIntact(t *testing.T) {
	o := sampleOrder()
	got, err := Mask(o, Viewer{UserID: "u-1"})
	if err != nil {
		t.Fatal(err)
	}
	if len(got.Items) != len(o.Items) {
		t.Fatalf("items = %d, want %d", len(got.Items), len(o.Items))
	}
	for i := range o.Items {
		if got.Items[i] != o.Items[i] {
			t.Fatalf("item %d = %+v, want %+v", i, got.Items[i], o.Items[i])
		}
	}
}

func TestMaskHidesAmountsFromNonOwner(t *testing.T) {
	o := sampleOrder()
	got, err := Mask(o, Viewer{UserID: "ops-9", Admin: true})
	if err != nil {
		t.Fatal(err)
	}
	if got.State != StatePaid {
		t.Fatalf("state = %q, want kept", got.State)
	}
	if len(got.Items) != 2 {
		t.Fatalf("items = %d, want 2", len(got.Items))
	}
	for i, it := range got.Items {
		if it.PriceCents != 0 {
			t.Fatalf("item %d price = %d, want 0", i, it.PriceCents)
		}
		if it.SKU != o.Items[i].SKU || it.Qty != o.Items[i].Qty {
			t.Fatalf("item %d structure changed: %+v", i, it)
		}
	}
}

func TestMaskDoesNotMutateInput(t *testing.T) {
	o := sampleOrder()
	if _, err := Mask(o, Viewer{UserID: "ops-9", Admin: true}); err != nil {
		t.Fatal(err)
	}
	if o.Items[0].PriceCents != 1999 || o.Items[1].PriceCents != 500 {
		t.Fatalf("input mutated: %+v", o.Items)
	}
}

func TestMaskReturnsIndependentSlice(t *testing.T) {
	o := sampleOrder()
	got, err := Mask(o, Viewer{UserID: "u-1"})
	if err != nil {
		t.Fatal(err)
	}
	got.Items[0].PriceCents = 1
	if o.Items[0].PriceCents != 1999 {
		t.Fatalf("masked copy shares backing array with input")
	}
}

func TestMaskRejectsStranger(t *testing.T) {
	if _, err := Mask(sampleOrder(), Viewer{UserID: "u-2"}); err == nil {
		t.Fatal("want error for stranger")
	}
}

TestMaskHidesAmountsFromNonOwner 用管理员身份验证“能看但金额置零”,TestMaskKeepsOwnerViewIntact 验证本人看到完整明细,TestMaskDoesNotMutateInput 和 TestMaskReturnsIndependentSlice 分别从原对象和切片别名两个角度防止副作用。TestVisibleRejectsAnonymous 则守住空用户 ID 不被误判为本人。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/order/ -run 'TestVisible|TestMask' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestVisibleAllowsOwnerAndAdmin
--- PASS: TestVisibleAllowsOwnerAndAdmin (0.00s)
=== RUN   TestVisibleRejectsStranger
--- PASS: TestVisibleRejectsStranger (0.00s)
=== RUN   TestVisibleRejectsAnonymous
--- PASS: TestVisibleRejectsAnonymous (0.00s)
=== RUN   TestMaskKeepsOwnerViewIntact
--- PASS: TestMaskKeepsOwnerViewIntact (0.00s)
=== RUN   TestMaskHidesAmountsFromNonOwner
--- PASS: TestMaskHidesAmountsFromNonOwner (0.00s)
=== RUN   TestMaskDoesNotMutateInput
--- PASS: TestMaskDoesNotMutateInput (0.00s)
=== RUN   TestMaskReturnsIndependentSlice
--- PASS: TestMaskReturnsIndependentSlice (0.00s)
=== RUN   TestMaskRejectsStranger
--- PASS: TestMaskRejectsStranger (0.00s)
PASS
ok  	webapp/internal/order	0.361s

输出里所有用例通过,只说明可见性与裁剪逻辑和测试一致,不说明字段白名单符合隐私要求。哪些角色能看地址、手机号、支付流水,仍然要由合规和业务共同定义。

小结

订单查询的坑通常不在 SQL,而在“谁能看”和“看什么”被分散到了多个接口。列表、详情、导出、客服后台各写一套 owner 判断,迟早会出现“列表能看、详情 403”或者更糟的越权。

Visible 只做一次可见性判断,Mask 在通过判断后按角色裁剪字段,并且返回独立副本。所有入口共用这两个函数,列表和详情的口径就不会漂移。安全上,路径参数永远不能作为身份来源;身份只能来自经过验证的会话或令牌。

阅读导航:上一节:2.2 订单状态机与超时关闭 · 下一节:3.1 支付渠道接入 。

常见错误与边界

  • 列表和详情各写一套 owner 判断。两套逻辑一旦不一致,就会出现“看得见、点不开”或越权。
  • 用路径参数里的用户 ID 做鉴权。路径参数由客户端控制,攻击者可以改成任意值。
  • 管理员分支只加在列表,忘了详情。客服能看到摘要却打不开订单,工单效率直接下降。
  • Mask 直接修改入参。后续对账或退款拿到被置零的金额,会产生错误结果。
  • 返回值和入参共享底层切片。修改返回值的明细会影响原订单,问题在并发请求下更难排查。
  • 只判断“是不是本人”,不区分管理员和普通用户。后台要么看不到,要么看到过多敏感字段。
  • 列表返回字段和详情返回字段各定一套。同一张订单在两个页面显示不同金额,用户无法判断哪个是真的。
  • 空用户 ID 被当作“本人”。系统单或脏数据会因此对匿名请求放行。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查