Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
30bf59a
chore: add guarded usage privacy patch
lien0219 Aug 1, 2026
72555ce
ci: apply and verify usage privacy feature
lien0219 Aug 1, 2026
721abfc
ci: persist usage privacy diagnostics
lien0219 Aug 1, 2026
2fb6fad
ci: run usage privacy patch on pull request
lien0219 Aug 1, 2026
35008cc
ci: record usage privacy failure diagnostics
github-actions[bot] Aug 1, 2026
18f54cc
ci: record usage privacy failure diagnostics
github-actions[bot] Aug 1, 2026
76b9541
fix: make usage privacy patch spacing tolerant
lien0219 Aug 1, 2026
ea96a4a
ci: apply usage privacy patch fixer
lien0219 Aug 1, 2026
c2dfac8
ci: serialize usage privacy verification on pull request
lien0219 Aug 1, 2026
2ca0836
ci: record usage privacy failure diagnostics
github-actions[bot] Aug 1, 2026
0845676
fix: preserve existing usage log type contracts
lien0219 Aug 1, 2026
7000542
ci: record usage privacy failure diagnostics
github-actions[bot] Aug 1, 2026
3faa89b
test: cover user billing detail visibility switches
lien0219 Aug 1, 2026
95218c8
ci: run focused usage privacy frontend tests
lien0219 Aug 1, 2026
d92a732
feat: control and redact user usage billing details
github-actions[bot] Aug 1, 2026
0b1bd0c
ci: restore audit checker removed by feature verification
lien0219 Aug 1, 2026
71243fc
fix: restore audit exception checker
github-actions[bot] Aug 1, 2026
b344d5c
test: document usage privacy visibility contract
lien0219 Aug 1, 2026
30db837
chore: add guarded privacy hardening patch
lien0219 Aug 1, 2026
faab156
ci: verify privacy hardening and security upgrade
lien0219 Aug 1, 2026
7b92737
ci: run privacy hardening verification on pull request
lien0219 Aug 1, 2026
dc1889d
fix: harden usage billing privacy controls
github-actions[bot] Aug 1, 2026
071b7e9
ci: restore tracked audit snapshot after verification
lien0219 Aug 1, 2026
d88758c
fix: restore tracked frontend audit snapshot
github-actions[bot] Aug 1, 2026
7baaeb1
test: cover nested multiplier redaction
lien0219 Aug 1, 2026
bee7e97
chore: add guarded API contract update
lien0219 Aug 1, 2026
2998537
ci: verify updated usage privacy API contracts
lien0219 Aug 1, 2026
bb955db
ci: run usage privacy contract update on pull request
lien0219 Aug 1, 2026
ea4e6ef
test: update admin settings API contracts
github-actions[bot] Aug 1, 2026
1cd0240
test: cover independent usage privacy switches
lien0219 Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion backend/internal/handler/admin/setting_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {

AffiliateEnabled: settings.AffiliateEnabled,

AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
UsageDetailShowUnitPrices: settings.UsageDetailShowUnitPrices,
UsageDetailShowRateMultiplier: settings.UsageDetailShowRateMultiplier,
UsageDetailShowOriginalCost: settings.UsageDetailShowOriginalCost,
}

// OpenAI fast policy (stored under a dedicated setting key)
Expand Down
23 changes: 22 additions & 1 deletion backend/internal/handler/admin/setting_handler_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,10 @@ type UpdateSettingsRequest struct {
AuthSourceGooglePlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_google_platform_quotas"`
AuthSourceDingTalkPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_dingtalk_platform_quotas"`

AllowUserViewErrorRequests *bool `json:"allow_user_view_error_requests"`
AllowUserViewErrorRequests *bool `json:"allow_user_view_error_requests"`
UsageDetailShowUnitPrices *bool `json:"usage_detail_show_unit_prices"`
UsageDetailShowRateMultiplier *bool `json:"usage_detail_show_rate_multiplier"`
UsageDetailShowOriginalCost *bool `json:"usage_detail_show_original_cost"`
}

// UpdateSettings 更新系统设置
Expand Down Expand Up @@ -1385,6 +1388,24 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.AllowUserViewErrorRequests
}(),
UsageDetailShowUnitPrices: func() bool {
if req.UsageDetailShowUnitPrices != nil {
return *req.UsageDetailShowUnitPrices
}
return previousSettings.UsageDetailShowUnitPrices
}(),
UsageDetailShowRateMultiplier: func() bool {
if req.UsageDetailShowRateMultiplier != nil {
return *req.UsageDetailShowRateMultiplier
}
return previousSettings.UsageDetailShowRateMultiplier
}(),
UsageDetailShowOriginalCost: func() bool {
if req.UsageDetailShowOriginalCost != nil {
return *req.UsageDetailShowOriginalCost
}
return previousSettings.UsageDetailShowOriginalCost
}(),
OpsMonitoringEnabled: func() bool {
if req.OpsMonitoringEnabled != nil {
return *req.OpsMonitoringEnabled
Expand Down
10 changes: 8 additions & 2 deletions backend/internal/handler/dto/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,10 @@ type SystemSettings struct {
DefaultPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"default_platform_quotas,omitempty"`

// 允许终端用户在用量页查看自己的失败请求
AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
UsageDetailShowUnitPrices bool `json:"usage_detail_show_unit_prices"`
UsageDetailShowRateMultiplier bool `json:"usage_detail_show_rate_multiplier"`
UsageDetailShowOriginalCost bool `json:"usage_detail_show_original_cost"`
}

type DefaultSubscriptionSetting struct {
Expand Down Expand Up @@ -373,7 +376,10 @@ type PublicSettings struct {

RiskControlEnabled bool `json:"risk_control_enabled"`

AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
UsageDetailShowUnitPrices bool `json:"usage_detail_show_unit_prices"`
UsageDetailShowRateMultiplier bool `json:"usage_detail_show_rate_multiplier"`
UsageDetailShowOriginalCost bool `json:"usage_detail_show_original_cost"`
}

type LoginAgreementDocument struct {
Expand Down
62 changes: 62 additions & 0 deletions backend/internal/handler/dto/usage_visibility.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package dto

import (
"encoding/json"

"github.com/Wei-Shaw/sub2api/internal/service"
)

var userUsageUnitPriceFields = []string{
"input_cost",
"output_cost",
"cache_creation_cost",
"cache_read_cost",
"image_input_cost",
"image_output_cost",
}

// UsageLogFromServiceWithVisibility converts a service usage record and removes
// disabled billing-calculation fields before the payload reaches JSON encoding.
// Admin endpoints continue using UsageLogFromServiceAdmin and remain unaffected.
func UsageLogFromServiceWithVisibility(log *service.UsageLog, visibility service.UsageDetailVisibility) map[string]any {
return RedactUsageLogForUser(UsageLogFromService(log), visibility)
}

// RedactUsageLogForUser performs response-level redaction rather than UI-only
// hiding. The omitted keys therefore cannot be recovered through DevTools.
func RedactUsageLogForUser(log *UsageLog, visibility service.UsageDetailVisibility) map[string]any {
if log == nil {
return nil
}
raw, err := json.Marshal(log)
if err != nil {
return map[string]any{}
}
payload := make(map[string]any)
if err := json.Unmarshal(raw, &payload); err != nil {
return map[string]any{}
}

if !visibility.ShowUnitPrices {
for _, field := range userUsageUnitPriceFields {
delete(payload, field)
}
}
if !visibility.ShowRateMultiplier {
delete(payload, "rate_multiplier")
redactNestedGroupRate(payload["group"])
if apiKey, ok := payload["api_key"].(map[string]any); ok {
redactNestedGroupRate(apiKey["group"])
}
}
if !visibility.ShowOriginalCost {
delete(payload, "total_cost")
}
return payload
}

func redactNestedGroupRate(value any) {
if group, ok := value.(map[string]any); ok {
delete(group, "rate_multiplier")
}
}
83 changes: 83 additions & 0 deletions backend/internal/handler/dto/usage_visibility_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package dto

import (
"encoding/json"
"testing"

"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)

func TestRedactUsageLogForUserOmitsDisabledBillingFields(t *testing.T) {
log := &UsageLog{
ID: 1,
InputCost: 1.25,
OutputCost: 2.5,
CacheCreationCost: 0.5,
CacheReadCost: 0.25,
ImageInputCost: 0.75,
ImageOutputCost: 1.75,
TotalCost: 7,
ActualCost: 0.42,
RateMultiplier: 0.06,
Group: &Group{ID: 1, RateMultiplier: 0.06},
APIKey: &APIKey{
ID: 2,
Group: &Group{ID: 1, RateMultiplier: 0.06},
},
}

payload := RedactUsageLogForUser(log, service.UsageDetailVisibility{})
raw, err := json.Marshal(payload)
require.NoError(t, err)
encoded := string(raw)

for _, key := range append(append([]string{}, userUsageUnitPriceFields...), "rate_multiplier", "total_cost") {
require.NotContains(t, encoded, `"`+key+`"`)
}
require.Contains(t, encoded, `"actual_cost":0.42`)
}

func TestRedactUsageLogForUserKeepsEnabledBillingFields(t *testing.T) {
log := &UsageLog{
ID: 1,
InputCost: 1.25,
OutputCost: 2.5,
TotalCost: 3.75,
ActualCost: 0.225,
RateMultiplier: 0.06,
}

payload := RedactUsageLogForUser(log, service.UsageDetailVisibility{
ShowUnitPrices: true,
ShowRateMultiplier: true,
ShowOriginalCost: true,
})
require.Equal(t, 1.25, payload["input_cost"])
require.Equal(t, 2.5, payload["output_cost"])
require.Equal(t, 3.75, payload["total_cost"])
require.Equal(t, 0.06, payload["rate_multiplier"])
}

func TestRedactUsageLogForUserSupportsIndependentSwitches(t *testing.T) {
log := &UsageLog{
ID: 1,
InputCost: 1.25,
OutputCost: 2.5,
TotalCost: 3.75,
ActualCost: 0.225,
RateMultiplier: 0.06,
}

payload := RedactUsageLogForUser(log, service.UsageDetailVisibility{
ShowUnitPrices: true,
ShowRateMultiplier: false,
ShowOriginalCost: true,
})

require.Equal(t, 1.25, payload["input_cost"])
require.Equal(t, 2.5, payload["output_cost"])
require.Equal(t, 3.75, payload["total_cost"])
require.NotContains(t, payload, "rate_multiplier")
require.Equal(t, 0.225, payload["actual_cost"])
}
5 changes: 4 additions & 1 deletion backend/internal/handler/setting_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,10 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {

RiskControlEnabled: settings.RiskControlEnabled,

AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
UsageDetailShowUnitPrices: settings.UsageDetailShowUnitPrices,
UsageDetailShowRateMultiplier: settings.UsageDetailShowRateMultiplier,
UsageDetailShowOriginalCost: settings.UsageDetailShowOriginalCost,
})
}

Expand Down
22 changes: 19 additions & 3 deletions backend/internal/handler/usage_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,17 @@ func (h *UsageHandler) List(c *gin.Context) {
return
}

out := make([]dto.UsageLog, 0, len(records))
visibility := service.UsageDetailVisibility{
ShowUnitPrices: true,
ShowRateMultiplier: true,
ShowOriginalCost: true,
}
if h.settingService != nil {
visibility = h.settingService.GetUsageDetailVisibility(c.Request.Context())
}
out := make([]map[string]any, 0, len(records))
for i := range records {
out = append(out, *dto.UsageLogFromService(&records[i]))
out = append(out, dto.UsageLogFromServiceWithVisibility(&records[i], visibility))
}
response.Paginated(c, out, result.Total, page, pageSize)
}
Expand Down Expand Up @@ -389,7 +397,15 @@ func (h *UsageHandler) GetByID(c *gin.Context) {
return
}

response.Success(c, dto.UsageLogFromService(record))
visibility := service.UsageDetailVisibility{
ShowUnitPrices: true,
ShowRateMultiplier: true,
ShowOriginalCost: true,
}
if h.settingService != nil {
visibility = h.settingService.GetUsageDetailVisibility(c.Request.Context())
}
response.Success(c, dto.UsageLogFromServiceWithVisibility(record, visibility))
}

// Stats handles getting usage statistics
Expand Down
10 changes: 8 additions & 2 deletions backend/internal/server/api_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -973,7 +973,10 @@ func TestAPIContracts(t *testing.T) {
"wechat_connect_redirect_url": "",
"wechat_connect_frontend_redirect_url": "/auth/wechat/callback",
"wechat_connect_scopes": "snsapi_login",
"allow_user_view_error_requests": false
"allow_user_view_error_requests": false,
"usage_detail_show_unit_prices": true,
"usage_detail_show_rate_multiplier": true,
"usage_detail_show_original_cost": true
}
}`,
},
Expand Down Expand Up @@ -1289,7 +1292,10 @@ func TestAPIContracts(t *testing.T) {
"auth_source_default_dingtalk_grant_on_signup": false,
"auth_source_default_dingtalk_grant_on_first_bind": false,
"force_email_on_third_party_signup": false,
"allow_user_view_error_requests": false
"allow_user_view_error_requests": false,
"usage_detail_show_unit_prices": true,
"usage_detail_show_rate_multiplier": true,
"usage_detail_show_original_cost": true
}
}`,
},
Expand Down
8 changes: 8 additions & 0 deletions backend/internal/service/domain_constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -549,3 +549,11 @@ const AdminAPIKeyPrefix = "admin-"
// SettingKeyAllowUserViewErrorRequests controls whether end users can view
// their own failed requests on the usage page. Default false (opt-in).
const SettingKeyAllowUserViewErrorRequests = "allow_user_view_error_requests"

// User usage cost-detail visibility. These are opt-out settings so existing
// installations preserve their current UI and API behavior after upgrading.
const (
SettingKeyUsageDetailShowUnitPrices = "usage_detail_show_unit_prices"
SettingKeyUsageDetailShowRateMultiplier = "usage_detail_show_rate_multiplier"
SettingKeyUsageDetailShowOriginalCost = "usage_detail_show_original_cost"
)
9 changes: 9 additions & 0 deletions backend/internal/service/setting_parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,12 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky: "",

SettingKeyAllowUserViewErrorRequests: "false",

SettingKeyUsageDetailShowUnitPrices: "true",

SettingKeyUsageDetailShowRateMultiplier: "true",

SettingKeyUsageDetailShowOriginalCost: "true",
}

return s.settingRepo.SetMultiple(ctx, defaults)
Expand Down Expand Up @@ -892,6 +898,9 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
}

result.AllowUserViewErrorRequests = settings[SettingKeyAllowUserViewErrorRequests] == "true" // default false
result.UsageDetailShowUnitPrices = !isFalseSettingValue(settings[SettingKeyUsageDetailShowUnitPrices])
result.UsageDetailShowRateMultiplier = !isFalseSettingValue(settings[SettingKeyUsageDetailShowRateMultiplier])
result.UsageDetailShowOriginalCost = !isFalseSettingValue(settings[SettingKeyUsageDetailShowOriginalCost])

return result
}
Expand Down
Loading