15 changed files with 492 additions and 29 deletions
@ -0,0 +1,37 @@ |
|||
--- |
|||
task_id: "5" |
|||
task_hash: "sha256:3d4c3ad3efc8a298211c74dd6c8da2d95fe4a8c1269fc58f0672ccc5f148e3c6" |
|||
next: "Task 6: history list/detail/delete" |
|||
completed: "Calculation save, policy version and idempotency" |
|||
evidence: "W3 suite 6/6 passed" |
|||
review: ".superpowers/sdd/reviews/T5.md" |
|||
risk: "Concurrent same-key race needs MySQL validation" |
|||
commit_start: "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7" |
|||
commit_end: "27dbc095e0413f9566bcc4ddc3bdb4f642535a6b" |
|||
created_at: "2026-09-20T08:34:50.804Z" |
|||
plan_hash: "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5" |
|||
plan_revision: 1 |
|||
--- |
|||
|
|||
# Checkpoint: 5 |
|||
|
|||
## Next |
|||
Task 6: history list/detail/delete |
|||
|
|||
## Completed |
|||
Calculation save, policy version and idempotency |
|||
|
|||
## Evidence |
|||
W3 suite 6/6 passed |
|||
|
|||
## Review |
|||
.superpowers/sdd/reviews/T5.md |
|||
|
|||
## Risk |
|||
Concurrent same-key race needs MySQL validation |
|||
|
|||
## Commit Start |
|||
4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7 |
|||
|
|||
## Commit End |
|||
27dbc095e0413f9566bcc4ddc3bdb4f642535a6b |
|||
@ -0,0 +1,37 @@ |
|||
--- |
|||
task_id: "6" |
|||
task_hash: "sha256:ee793f06e3bc8e83ed5836909b204f7234a68754d4ce938ae9fd927be426c7e6" |
|||
next: "Task 7: authorization, logging privacy and account deletion cleanup" |
|||
completed: "History list/detail/delete and snapshot responses" |
|||
evidence: "W3 suite 6/6 passed" |
|||
review: ".superpowers/sdd/reviews/T6.md" |
|||
risk: "Snapshot JSON compatibility requires MySQL validation" |
|||
commit_start: "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7" |
|||
commit_end: "27dbc095e0413f9566bcc4ddc3bdb4f642535a6b" |
|||
created_at: "2026-09-20T08:34:50.905Z" |
|||
plan_hash: "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5" |
|||
plan_revision: 1 |
|||
--- |
|||
|
|||
# Checkpoint: 6 |
|||
|
|||
## Next |
|||
Task 7: authorization, logging privacy and account deletion cleanup |
|||
|
|||
## Completed |
|||
History list/detail/delete and snapshot responses |
|||
|
|||
## Evidence |
|||
W3 suite 6/6 passed |
|||
|
|||
## Review |
|||
.superpowers/sdd/reviews/T6.md |
|||
|
|||
## Risk |
|||
Snapshot JSON compatibility requires MySQL validation |
|||
|
|||
## Commit Start |
|||
4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7 |
|||
|
|||
## Commit End |
|||
27dbc095e0413f9566bcc4ddc3bdb4f642535a6b |
|||
@ -0,0 +1,37 @@ |
|||
--- |
|||
task_id: "7" |
|||
task_hash: "sha256:a3bf97aab3db413f757f95db0c0698d2a2041fe87309be003ccff6f2a267f162" |
|||
next: "Wave W4 task 8: full module tests, OpenAPI and docs" |
|||
completed: "Authorization, privacy boundaries and account deletion purge" |
|||
evidence: "W3 suite 6/6 passed" |
|||
review: ".superpowers/sdd/reviews/T7.md" |
|||
risk: "Legacy deletion tests must remain green after constructor change" |
|||
commit_start: "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7" |
|||
commit_end: "27dbc095e0413f9566bcc4ddc3bdb4f642535a6b" |
|||
created_at: "2026-09-20T08:34:51.016Z" |
|||
plan_hash: "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5" |
|||
plan_revision: 1 |
|||
--- |
|||
|
|||
# Checkpoint: 7 |
|||
|
|||
## Next |
|||
Wave W4 task 8: full module tests, OpenAPI and docs |
|||
|
|||
## Completed |
|||
Authorization, privacy boundaries and account deletion purge |
|||
|
|||
## Evidence |
|||
W3 suite 6/6 passed |
|||
|
|||
## Review |
|||
.superpowers/sdd/reviews/T7.md |
|||
|
|||
## Risk |
|||
Legacy deletion tests must remain green after constructor change |
|||
|
|||
## Commit Start |
|||
4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7 |
|||
|
|||
## Commit End |
|||
27dbc095e0413f9566bcc4ddc3bdb4f642535a6b |
|||
@ -0,0 +1,10 @@ |
|||
{ |
|||
"status": "pass", |
|||
"base": "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7", |
|||
"head": "27dbc095e0413f9566bcc4ddc3bdb4f642535a6b", |
|||
"report": ".superpowers\\sdd\\reviews\\W3.md", |
|||
"report_sha256": "sha256:7d2823941f43fd101c1271be77703e6ae614650c64c7c1fe1484a7cae41f68fa", |
|||
"plan_hash": "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5", |
|||
"plan_revision": 1, |
|||
"recorded_at": "2026-09-20T08:34:51.607Z" |
|||
} |
|||
@ -1,14 +1,10 @@ |
|||
# n05-salary-calc 执行进度 |
|||
|
|||
## W1 - 数据基础:完成 |
|||
- V40、实体、Repository、359 城分层 V41、迁移和种子测试;3/3 通过。 |
|||
- W1 review receipt:pass。 |
|||
## W1-W3 |
|||
- 全部完成并已有 pass review receipt。 |
|||
|
|||
## W2 - 城市查询与计算引擎:完成 |
|||
- 城市列表/详情、STUDENT 权限、dataLevel 和 BigDecimal 计算引擎;15/15 通过。 |
|||
- W2 review receipt:pass。 |
|||
|
|||
## W3 - 历史、幂等、权限与注销:完成待 review |
|||
- 计算并保存、政策版本冲突、幂等重放/冲突、历史列表/详情/物理删除、跨用户隔离和账号注销清理。 |
|||
- 验证:`mvn -Dtest=SalaryAuthorizationIntegrationTest,SalaryCalculationEndToEndIntegrationTest,SalaryAccountDeletionIntegrationTest test`,6/6 通过。 |
|||
- 隐私:薪资包无直接日志写入;API 不返回 requestHash、idempotencyKey 或 studentUserId。 |
|||
## W4 - 发布证明与文档 |
|||
- Task 8:完成。薪资专项 24/24,OpenAPI 139 paths / 178 operations,API/联调/路线图文档已更新。 |
|||
- Task 9:阻塞。完整 `mvn clean verify` 运行 1516 tests,仍有既有 2 failures / 9 errors,且失败不位于 salarycalculator。 |
|||
- Task 9 Docker:本机无 `docker`、`podman` 或 `nerdctl` CLI/服务,WSL 访问被拒绝;未使用 3307 常驻实例执行任何迁移或黑盒写入。 |
|||
- 一次性容器专用脚本:`scripts/verify_salary_calculator_blackbox.py`,待 Docker 可用后运行。 |
|||
|
|||
@ -0,0 +1,10 @@ |
|||
{ |
|||
"status": "pass", |
|||
"base": "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7", |
|||
"head": "27dbc095e0413f9566bcc4ddc3bdb4f642535a6b", |
|||
"report": ".superpowers\\sdd\\reviews\\W3.md", |
|||
"report_sha256": "sha256:7d2823941f43fd101c1271be77703e6ae614650c64c7c1fe1484a7cae41f68fa", |
|||
"plan_hash": "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5", |
|||
"plan_revision": 1, |
|||
"recorded_at": "2026-09-20T08:34:51.607Z" |
|||
} |
|||
@ -0,0 +1,7 @@ |
|||
# Wave W4 blocked |
|||
|
|||
- Task 8: pass evidence recorded in progress ledger. |
|||
- Task 9: blocked by missing Docker runtime. |
|||
- Docker check: `docker`, `podman`, and `nerdctl` are absent from PATH and standard install paths; Docker Desktop service/files not found; WSL invocation is access denied. |
|||
- Policy: the resident MySQL at 127.0.0.1:3307 is prohibited for W4 acceptance and was not migrated or written to. |
|||
- Resume command: install/enable Docker, start app against a disposable MySQL container, then run `python scripts/verify_salary_calculator_blackbox.py` with explicit black-box environment variables. |
|||
File diff suppressed because one or more lines are too long
@ -0,0 +1,158 @@ |
|||
# 薪资计算器 API(N05) |
|||
|
|||
> Base URL:`/api/v1` |
|||
> 认证:`Authorization: Bearer <access-token>` |
|||
> 角色:当前登录用户必须具备 `STUDENT` 角色。 |
|||
|
|||
## 1. 能力边界 |
|||
|
|||
- 输入税前月薪,估算个人五险一金、大病固定费和当月个税后的到手工资。 |
|||
- 采用居民个人综合所得累计预扣法;`monthIndex` 表示当前工资口径下的累计计税月份数,而不是自然月编号。 |
|||
- 支持用户覆盖社保基数、公积金基数和个人比例。 |
|||
- 城市政策以地级行政区、直辖市和省直辖县级市为最小单元,不拆分到区县。 |
|||
- `dataLevel=CITY` 表示城市级参数;`dataLevel=PROVINCE_FALLBACK` 表示由于公开城市级资料不足而使用省级回退参数,结果精度较低。 |
|||
- 所有结果均为估算值,实际以工资单、当地政策和税务申报为准。 |
|||
|
|||
## 2. 查询城市列表 |
|||
|
|||
`GET /salary-calculator/cities` |
|||
|
|||
返回全部启用城市摘要,按省份编码和城市编码排序。 |
|||
|
|||
响应示例: |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": [ |
|||
{ |
|||
"cityCode": "110000", |
|||
"provinceCode": "110000", |
|||
"provinceName": "北京市", |
|||
"cityName": "北京市", |
|||
"policyVersion": "2026-09-20-v1", |
|||
"policyEffectiveDate": "2026-07-01", |
|||
"dataLevel": "CITY" |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
## 3. 查询城市默认参数 |
|||
|
|||
`GET /salary-calculator/cities/{cityCode}` |
|||
|
|||
返回城市级或省级回退的当前默认参数。计算请求必须原样携带响应中的 `policyVersion` 作为 `expectedPolicyVersion`。 |
|||
|
|||
## 4. 计算并保存 |
|||
|
|||
`POST /salary-calculator/calculations` |
|||
|
|||
请求示例: |
|||
|
|||
```json |
|||
{ |
|||
"cityCode": "110000", |
|||
"grossMonthlySalary": 10000.00, |
|||
"monthIndex": 1, |
|||
"specialAdditionalDeduction": 0.00, |
|||
"expectedPolicyVersion": "2026-09-20-v1", |
|||
"idempotencyKey": "optional-client-key", |
|||
"overrides": { |
|||
"socialSecurityBase": 10000.00, |
|||
"housingFundBase": 10000.00, |
|||
"pensionRate": 0.08, |
|||
"medicalRate": 0.02, |
|||
"unemploymentRate": 0.005, |
|||
"housingFundRate": 0.12, |
|||
"seriousIllnessMonthlyFee": 0.00 |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- `cityCode`、`grossMonthlySalary`、`expectedPolicyVersion` 必填。 |
|||
- `monthIndex` 默认 1,范围 1–12。 |
|||
- `specialAdditionalDeduction` 默认 0。 |
|||
- `idempotencyKey` 可选,最长 64 字符;同用户同键同规范化请求返回原历史,同键不同请求返回 `409/9804`。 |
|||
- 覆盖基数必须位于对应城市范围内;比例为 0 时允许基数为 0。 |
|||
|
|||
成功响应: |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": { |
|||
"historyId": 123, |
|||
"createdAt": "2026-09-20T16:00:00", |
|||
"result": { |
|||
"cityCode": "110000", |
|||
"cityName": "北京市", |
|||
"dataLevel": "CITY", |
|||
"policyVersion": "2026-09-20-v1", |
|||
"policyEffectiveDate": "2026-07-01", |
|||
"taxRulesVersion": "CN_IIT_2019_V1", |
|||
"estimate": true, |
|||
"monthIndex": 1, |
|||
"grossMonthlySalary": 10000.00, |
|||
"socialSecurityBase": 10000.00, |
|||
"housingFundBase": 10000.00, |
|||
"pension": 800.00, |
|||
"medical": 200.00, |
|||
"unemployment": 50.00, |
|||
"housingFund": 1200.00, |
|||
"currentMonthTax": 82.50, |
|||
"netSalary": 7667.50 |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
## 5. 查询历史 |
|||
|
|||
`GET /salary-calculator/calculations?page=0&size=20` |
|||
|
|||
- 仅返回当前用户的记录。 |
|||
- 按创建时间倒序、历史 ID 倒序。 |
|||
- `size` 最大 100。 |
|||
|
|||
`GET /salary-calculator/calculations/{id}` |
|||
|
|||
返回输入、政策、覆盖项和结果三组完整 JSON 快照。访问他人历史统一返回 `404/9803`。 |
|||
|
|||
## 6. 删除历史 |
|||
|
|||
`DELETE /salary-calculator/calculations/{id}` |
|||
|
|||
物理删除本人历史;成功后再次查询返回 `404/9803`。删除后原幂等键可重新使用。 |
|||
|
|||
## 7. 错误码 |
|||
|
|||
| HTTP | code | 含义 | |
|||
|---|---|---| |
|||
| 400 | 9802 | 工资、比例、基数或分页参数非法 | |
|||
| 401 | 401 | 未登录或 Token 无效 | |
|||
| 403 | 2002 | 已登录但不是 `STUDENT` | |
|||
| 404 | 9801 | 城市不存在、停用或未配置 | |
|||
| 404 | 9803 | 历史不存在或不属于当前用户 | |
|||
| 409 | 9804 | 幂等键冲突 | |
|||
| 409 | 9805 | 城市政策版本已变化 | |
|||
|
|||
`9805` 响应会在 `data` 中返回最新 `policyVersion` 和 `policyEffectiveDate`,客户端应重新加载城市详情后再试算。 |
|||
|
|||
## 8. 计算口径 |
|||
|
|||
```text |
|||
社保基数 = 用户覆盖值,否则 min(max(税前月薪, 社保下限), 社保上限) |
|||
公积金基数 = 用户覆盖值,否则 min(max(税前月薪, 公积金下限), 公积金上限) |
|||
|
|||
累计收入 = 税前月薪 × monthIndex |
|||
累计基本减除费用 = 5000 × monthIndex |
|||
累计专项附加扣除 = 月度专项附加扣除 × monthIndex |
|||
累计应纳税所得额 = max(累计收入 - 累计个人社保 - 累计公积金 - 累计大病费 |
|||
- 累计基本减除费用 - 累计专项附加扣除, 0) |
|||
当月个税 = max(累计应纳税额 - 累计已预扣税额, 0) |
|||
到手工资 = max(税前月薪 - 个人养老 - 个人医疗 - 个人失业 - 个人公积金 |
|||
- 大病固定费 - 当月个税, 0) |
|||
``` |
|||
@ -0,0 +1,131 @@ |
|||
"""N05 salary calculator black-box verification. |
|||
|
|||
Run only against an isolated, disposable MySQL container and an app wired to it. |
|||
Required environment variables: |
|||
BASE_URL=http://127.0.0.1:<PORT>/api/v1 |
|||
DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_NAME |
|||
""" |
|||
import os |
|||
import sys |
|||
import time |
|||
|
|||
import pymysql |
|||
import requests |
|||
|
|||
BASE = os.environ.get("BASE_URL", "").rstrip("/") |
|||
DB = { |
|||
"host": os.environ["DB_HOST"], |
|||
"port": int(os.environ["DB_PORT"]), |
|||
"user": os.environ["DB_USERNAME"], |
|||
"password": os.environ["DB_PASSWORD"], |
|||
"database": os.environ["DB_NAME"], |
|||
} |
|||
if not BASE: |
|||
raise SystemExit("BASE_URL is required") |
|||
|
|||
|
|||
def db(): |
|||
return pymysql.connect(**DB, autocommit=True) |
|||
|
|||
|
|||
def sql_one(query, args=()): |
|||
with db() as conn: |
|||
with conn.cursor() as cur: |
|||
cur.execute(query, args) |
|||
return cur.fetchone() |
|||
|
|||
|
|||
def sql_all(query, args=()): |
|||
with db() as conn: |
|||
with conn.cursor() as cur: |
|||
cur.execute(query, args) |
|||
return cur.fetchall() |
|||
|
|||
|
|||
def login_student(phone): |
|||
r = requests.post(f"{BASE}/auth/sms/send", params={"phone": phone, "purpose": "login"}, timeout=10) |
|||
assert r.status_code == 200, r.text |
|||
code = None |
|||
for _ in range(20): |
|||
row = sql_one( |
|||
"SELECT code FROM sms_verification_codes " |
|||
"WHERE phone=%s AND purpose='login' AND used=0 ORDER BY id DESC LIMIT 1", |
|||
(phone,), |
|||
) |
|||
if row: |
|||
code = row[0] |
|||
break |
|||
time.sleep(0.25) |
|||
assert code, "mock SMS code not found" |
|||
r = requests.post(f"{BASE}/auth/login", json={"phone": phone, "code": code, "purpose": "login"}, timeout=10) |
|||
assert r.status_code == 200, r.text |
|||
return r.json()["data"]["accessToken"] |
|||
|
|||
|
|||
def main(): |
|||
versions = {row[0] for row in sql_all("SELECT version FROM flyway_schema_history")} |
|||
assert "40" in versions and "41" in versions, versions |
|||
|
|||
assert sql_one("SELECT COUNT(*) FROM salary_city_policy")[0] == 359 |
|||
levels = dict(sql_all("SELECT data_level, COUNT(*) FROM salary_city_policy GROUP BY data_level")) |
|||
assert levels == {"CITY": 12, "PROVINCE_FALLBACK": 347}, levels |
|||
|
|||
phone = os.environ.get("BLACKBOX_PHONE", f"139{int(time.time()) % 100000000:08d}") |
|||
token = login_student(phone) |
|||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} |
|||
|
|||
cities = requests.get(f"{BASE}/salary-calculator/cities", headers=headers, timeout=10) |
|||
assert cities.status_code == 200 and len(cities.json()["data"]) == 359, cities.text |
|||
|
|||
city = requests.get(f"{BASE}/salary-calculator/cities/110000", headers=headers, timeout=10) |
|||
assert city.status_code == 200 and city.json()["data"]["dataLevel"] == "CITY", city.text |
|||
|
|||
payload = { |
|||
"cityCode": "110000", |
|||
"grossMonthlySalary": 10000.00, |
|||
"monthIndex": 1, |
|||
"specialAdditionalDeduction": 0.00, |
|||
"expectedPolicyVersion": city.json()["data"]["policyVersion"], |
|||
"idempotencyKey": f"blackbox-{phone}", |
|||
"overrides": None, |
|||
} |
|||
first = requests.post(f"{BASE}/salary-calculator/calculations", headers=headers, json=payload, timeout=10) |
|||
assert first.status_code == 200, first.text |
|||
history_id = first.json()["data"]["historyId"] |
|||
assert str(first.json()["data"]["result"]["netSalary"]) == "7667.50", first.text |
|||
|
|||
replay = requests.post(f"{BASE}/salary-calculator/calculations", headers=headers, json=payload, timeout=10) |
|||
assert replay.status_code == 200 and replay.json()["data"]["historyId"] == history_id, replay.text |
|||
|
|||
conflict_payload = dict(payload) |
|||
conflict_payload["grossMonthlySalary"] = 12000.00 |
|||
conflict = requests.post(f"{BASE}/salary-calculator/calculations", headers=headers, json=conflict_payload, timeout=10) |
|||
assert conflict.status_code == 409 and conflict.json()["code"] == 9804, conflict.text |
|||
|
|||
stale_payload = dict(payload) |
|||
stale_payload["idempotencyKey"] = "stale-" + phone |
|||
stale_payload["expectedPolicyVersion"] = "stale-version" |
|||
stale = requests.post(f"{BASE}/salary-calculator/calculations", headers=headers, json=stale_payload, timeout=10) |
|||
assert stale.status_code == 409 and stale.json()["code"] == 9805, stale.text |
|||
assert stale.json()["data"]["latestPolicyVersion"] == city.json()["data"]["policyVersion"] |
|||
|
|||
history = requests.get(f"{BASE}/salary-calculator/calculations", headers=headers, timeout=10) |
|||
assert history.status_code == 200 and history.json()["data"]["total"] == 1, history.text |
|||
|
|||
detail = requests.get(f"{BASE}/salary-calculator/calculations/{history_id}", headers=headers, timeout=10) |
|||
assert detail.status_code == 200 and detail.json()["data"]["policySnapshot"]["dataLevel"] == "CITY", detail.text |
|||
|
|||
deleted = requests.delete(f"{BASE}/salary-calculator/calculations/{history_id}", headers=headers, timeout=10) |
|||
assert deleted.status_code == 200, deleted.text |
|||
missing = requests.get(f"{BASE}/salary-calculator/calculations/{history_id}", headers=headers, timeout=10) |
|||
assert missing.status_code == 404 and missing.json()["code"] == 9803, missing.text |
|||
|
|||
print("PASS: N05 salary calculator disposable-MySQL black box") |
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
try: |
|||
main() |
|||
except Exception as exc: |
|||
print(f"FAIL: {type(exc).__name__}: {exc}", file=sys.stderr) |
|||
raise |
|||
Loading…
Reference in new issue