fix: support idempotent actions over HTTP
This commit is contained in:
@@ -0,0 +1,22 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
STATIC_ROOT = Path(settings.BASE_DIR) / "static"
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_幂等键兼容非安全上下文():
|
||||||
|
source = (STATIC_ROOT / "js" / "app.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "function createIdempotencyKey()" in source
|
||||||
|
assert "crypto.randomUUID()" not in source
|
||||||
|
assert source.count('"Idempotency-Key": createIdempotencyKey()') == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_css_答题提交按钮可见且长弹窗可滚动():
|
||||||
|
source = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert ".choice-list .primary-button" in source
|
||||||
|
assert "background: var(--green); color: white; text-align: center" in source
|
||||||
|
assert "max-height: calc(100dvh - 30px)" in source
|
||||||
|
assert "overflow-y: auto" in source
|
||||||
@@ -198,7 +198,7 @@ button { color: inherit; }
|
|||||||
.video-complete { position: absolute; right: 12px; top: 12px; background: var(--green); color: white; border-radius: 99px; padding: 5px 8px; font-size: 9px; }
|
.video-complete { position: absolute; right: 12px; top: 12px; background: var(--green); color: white; border-radius: 99px; padding: 5px 8px; font-size: 9px; }
|
||||||
.video-body { padding: 19px; }.ability-badge { display: inline-block; border: 1px solid var(--badge-color); color: var(--badge-color); border-radius: 8px; padding: 5px 8px; font-size: 10px; }.video-body h3 { min-height: 48px; margin: 14px 0 16px; font: 20px/1.3 Georgia, serif; }.video-metrics, .video-author { display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; }.video-author { justify-content: flex-start; gap: 8px; align-items: center; margin-top: 14px; }.video-author i { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: var(--badge-color); color: white; font-style: normal; }
|
.video-body { padding: 19px; }.ability-badge { display: inline-block; border: 1px solid var(--badge-color); color: var(--badge-color); border-radius: 8px; padding: 5px 8px; font-size: 10px; }.video-body h3 { min-height: 48px; margin: 14px 0 16px; font: 20px/1.3 Georgia, serif; }.video-metrics, .video-author { display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; }.video-author { justify-content: flex-start; gap: 8px; align-items: center; margin-top: 14px; }.video-author i { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: var(--badge-color); color: white; font-style: normal; }
|
||||||
.video-dialog { width: min(760px, calc(100% - 30px)); }.video-player { min-height: 300px; border-radius: 17px; background: var(--ink); display: grid; place-items: center; color: white; text-align: center; padding: 30px; }.video-player span { display: block; color: var(--lime); font-size: 55px; }.video-player p { color: #aeb9b1; }.video-detail-meta { display: flex; gap: 12px; flex-wrap: wrap; color: var(--muted); font-size: 11px; margin: 18px 0; }.video-dialog h2 { font: 31px/1.2 Georgia, serif; }.video-dialog .video-description { color: var(--muted); line-height: 1.8; }.video-dialog-actions { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 22px; }
|
.video-dialog { width: min(760px, calc(100% - 30px)); }.video-player { min-height: 300px; border-radius: 17px; background: var(--ink); display: grid; place-items: center; color: white; text-align: center; padding: 30px; }.video-player span { display: block; color: var(--lime); font-size: 55px; }.video-player p { color: #aeb9b1; }.video-detail-meta { display: flex; gap: 12px; flex-wrap: wrap; color: var(--muted); font-size: 11px; margin: 18px 0; }.video-dialog h2 { font: 31px/1.2 Georgia, serif; }.video-dialog .video-description { color: var(--muted); line-height: 1.8; }.video-dialog-actions { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 22px; }
|
||||||
dialog { width: min(460px, calc(100% - 30px)); border: 1px solid var(--line); border-radius: 20px; padding: 30px; color: var(--ink); background: #faf9f3; box-shadow: 0 30px 100px rgba(0,0,0,.22); }
|
dialog { width: min(460px, calc(100% - 30px)); max-height: calc(100vh - 30px); max-height: calc(100dvh - 30px); overflow-y: auto; border: 1px solid var(--line); border-radius: 20px; padding: 30px; color: var(--ink); background: #faf9f3; box-shadow: 0 30px 100px rgba(0,0,0,.22); }
|
||||||
dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); }
|
dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); }
|
||||||
.dialog-close { position: absolute; right: 16px; top: 13px; border: 0; background: transparent; font-size: 25px; cursor: pointer; }
|
.dialog-close { position: absolute; right: 16px; top: 13px; border: 0; background: transparent; font-size: 25px; cursor: pointer; }
|
||||||
.dialog-tabs { display: flex; border-bottom: 1px solid var(--line); margin-bottom: 25px; }
|
.dialog-tabs { display: flex; border-bottom: 1px solid var(--line); margin-bottom: 25px; }
|
||||||
@@ -213,6 +213,9 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); }
|
|||||||
.choice-list { display: grid; gap: 9px; margin-top: 25px; }
|
.choice-list { display: grid; gap: 9px; margin-top: 25px; }
|
||||||
.choice-list button { text-align: left; border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: white; cursor: pointer; }
|
.choice-list button { text-align: left; border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: white; cursor: pointer; }
|
||||||
.choice-list button:hover { border-color: var(--green); }
|
.choice-list button:hover { border-color: var(--green); }
|
||||||
|
.choice-list .primary-button { width: 100%; border-color: var(--green); background: var(--green); color: white; text-align: center; font-weight: 650; }
|
||||||
|
.choice-list .primary-button:hover { border-color: var(--green); background: var(--green); }
|
||||||
|
.choice-list button:disabled { cursor: wait; opacity: .65; }
|
||||||
.quiz-progress { height: 4px; background: #e4e3da; margin: 15px 0 30px; }.quiz-progress i { display: block; height: 100%; background: var(--green); transition: width .2s; }
|
.quiz-progress { height: 4px; background: #e4e3da; margin: 15px 0 30px; }.quiz-progress i { display: block; height: 100%; background: var(--green); transition: width .2s; }
|
||||||
.toast { position: fixed; right: 25px; bottom: 25px; background: var(--ink); color: white; border-radius: 12px; padding: 13px 17px; opacity: 0; transform: translateY(10px); pointer-events: none; transition: .2s; z-index: 20; }
|
.toast { position: fixed; right: 25px; bottom: 25px; background: var(--ink); color: white; border-radius: 12px; padding: 13px 17px; opacity: 0; transform: translateY(10px); pointer-events: none; transition: .2s; z-index: 20; }
|
||||||
.toast.show { opacity: 1; transform: none; }
|
.toast.show { opacity: 1; transform: none; }
|
||||||
|
|||||||
@@ -17,6 +17,32 @@ function csrfToken() {
|
|||||||
return match ? decodeURIComponent(match[1]) : "";
|
return match ? decodeURIComponent(match[1]) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createIdempotencyKey() {
|
||||||
|
const cryptoApi = globalThis.crypto;
|
||||||
|
if (typeof cryptoApi?.randomUUID === "function") {
|
||||||
|
return cryptoApi.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
if (typeof cryptoApi?.getRandomValues === "function") {
|
||||||
|
cryptoApi.getRandomValues(bytes);
|
||||||
|
} else {
|
||||||
|
for (let index = 0; index < bytes.length; index += 1) {
|
||||||
|
bytes[index] = Math.floor(Math.random() * 256);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0"));
|
||||||
|
return [
|
||||||
|
hex.slice(0, 4).join(""),
|
||||||
|
hex.slice(4, 6).join(""),
|
||||||
|
hex.slice(6, 8).join(""),
|
||||||
|
hex.slice(8, 10).join(""),
|
||||||
|
hex.slice(10).join(""),
|
||||||
|
].join("-");
|
||||||
|
}
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
const headers = { Accept: "application/json", ...(options.headers || {}) };
|
const headers = { Accept: "application/json", ...(options.headers || {}) };
|
||||||
if (options.body && typeof options.body !== "string") {
|
if (options.body && typeof options.body !== "string") {
|
||||||
@@ -165,7 +191,7 @@ function renderStoryNode(run) {
|
|||||||
try {
|
try {
|
||||||
const next = await api(`math-life/runs/${run.run_id}/choice/`, {
|
const next = await api(`math-life/runs/${run.run_id}/choice/`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Idempotency-Key": crypto.randomUUID() },
|
headers: { "Idempotency-Key": createIdempotencyKey() },
|
||||||
body: { choice_index: choice.index },
|
body: { choice_index: choice.index },
|
||||||
});
|
});
|
||||||
renderStoryNode(next);
|
renderStoryNode(next);
|
||||||
@@ -341,7 +367,7 @@ function renderAttempt(attempt) {
|
|||||||
try {
|
try {
|
||||||
const result = await api(`contests/attempts/${attempt.attempt_id}/submit/`, {
|
const result = await api(`contests/attempts/${attempt.attempt_id}/submit/`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Idempotency-Key": crypto.randomUUID() },
|
headers: { "Idempotency-Key": createIdempotencyKey() },
|
||||||
body: { answers },
|
body: { answers },
|
||||||
});
|
});
|
||||||
renderAttempt(result);
|
renderAttempt(result);
|
||||||
|
|||||||
Reference in New Issue
Block a user